Тут шановний
sharpc лякає людей синтаксисом C++, а мені згадались два баги компілятора (а точніше реалізації стандартної бібліотеки), на які ми на роботі наштовхнулись буквально минулого тижня.
Почнемо з простого, зовсім невинного і в чомусь навіть примітивного коду:
#include
#include
typedef std::function
unary;
typedef std::function binary;
void call(unary func)
{
std::cout << func(0) << "\n";
}
void call(binary func)
{
std::cout << func(1, 2) << "\n";
}
bool test(int a, int b)
{
return a < b;
}
int main()
{
call(test);
call([](int a){ return a == 0; });
return 0;
}
_Winnie C++ Colorizer
Люди ризикові, які живуть на 10 секунд у майбутньому і користуються останніми версіями компіляторів цього разу у виграші. Вони при компіляції цього коду навіть нічого і не відчують. А от пересічні громадяни, такі як ви і я, що користуються стабільними версіями компіляторів, обов’язково отримають по лобі підводними граблями:
$ g++-4.4.7 -W -Wall -Wextra -pedantic -std=c++0x test.cpp -o test
test.cpp: In function ‘int main()’:
test.cpp:24: error: call of overloaded ‘call(bool (&)(int, int))’ is ambiguous
test.cpp:7: note: candidates are: void call(unary)
test.cpp:12: note: void call(binary)
test.cpp:25: error: expected primary-expression before ‘[’ token
test.cpp:25: error: expected primary-expression before ‘]’ token
test.cpp:25: error: expected primary-expression before ‘int’
Ну добре, 4.4 це взагалі моветон. Тут навіть лямбд немає! Але зверніть увагу на перше повідомлення про помилку - воно вам не здається дивним? Для більшого ефекту спробуємо інші версії:
$ g++-4.5.4 -W -Wall -Wextra -pedantic -std=c++0x test.cpp -o test
test.cpp: In function ‘int main()’:
test.cpp:24:14: error: call of overloaded ‘call(bool (&)(int, int))’ is ambiguous
test.cpp:7:6: note: candidates are: void call(unary)
test.cpp:12:6: note: void call(binary)
test.cpp:25:37: error: call of overloaded ‘call(main()::)’ is ambiguous
test.cpp:7:6: note: candidates are: void call(unary)
test.cpp:12:6: note: void call(binary)
$ g++-4.6.4 -W -Wall -Wextra -pedantic -std=c++0x test.cpp -o test
test.cpp: In function ‘int main()’:
test.cpp:24:14: error: call of overloaded ‘call(bool (&)(int, int))’ is ambiguous
test.cpp:24:14: note: candidates are:
test.cpp:7:6: note: void call(unary)
test.cpp:12:6: note: void call(binary)
test.cpp:25:37: error: call of overloaded ‘call(main()::)’ is ambiguous
test.cpp:25:37: note: candidates are:
test.cpp:7:6: note: void call(unary)
test.cpp:12:6: note: void call(binary)
$ g++-4.7.3 -W -Wall -Wextra -pedantic -std=c++0x test.cpp -o test
test.cpp: In function ‘int main()’:
test.cpp:24:14: error: call of overloaded ‘call(bool (&)(int, int))’ is ambiguous
test.cpp:24:14: note: candidates are:
test.cpp:7:6: note: void call(unary)
test.cpp:12:6: note: void call(binary)
test.cpp:25:37: error: call of overloaded ‘call(main()::)’ is ambiguous
test.cpp:25:37: note: candidates are:
test.cpp:7:6: note: void call(unary)
test.cpp:12:6: note: void call(binary)
До речі, на роботі у нас 4.6.3.
Компілятор намагається сказати нам що він не знає який варіант функції call вибрати. І це дивно, бо сигнатура функції що передається їй як параметр чітко визначена. Давайте розберемось, що відбувається під час виклику функції call. По-перше, що таке std::function<>: це „функціональний об’єкт“ (прості C++-ники скажуть „функтор“, але ми-то з вами знаємо...) - об’єкт що містить у собі метод operator(), який дозволяє „викликати“ такий об’єкт на виконання як звичайну функцію. Таким макаром у C++ реалізована підтримка функцій як first-class citizens. Будь-яку функцію можна „упакувати“ у такий функціональний об’єкт, включаючи лямбда-функції, - головне щоб сигнатура співпадала.
Таким чином функція call очікує на вхід параметр типу „функціональний об’єкт“, а ми підсовуємо їй дещо інше. У першому випадку це вказівник на звичайну функцію, у другому - лямбда-функцію. До речі, це прекрасний приклад того що лямда-функції мають свій власний тип, а не std::function<>. Щоб викликати call з таким аргументом треба його перетворити на той тип, який хоче call. Тобто у цьому місці викликається конструктор std::function<>. І схоже що компілятор вважає можливим сконструювати обидва типи із унарної функції і бінарної лямбди! Але як таке можливе? Давайте поглянемо на цей конструктор:
template
function(_Functor __f,
typename enable_ifis_integral<_Functor>::value, _Useless>::type = _Useless());
_Winnie C++ Colorizer
Він досить таки простий: це шаблонна функція, яка приймає на вхід аргумент шаблонного типу. А це значить що конструктор може бути викликаний з чим завгодно. З яким завгодно аргументом. Будь якого типу.
Ну, насправді, це не зовсім правда. Якщо ви підсунете йому, скажімо, char* - він обуриться і не стане з вами більше розмовляти. Частково за це відповідає неявний другий аргумент конструктора: якщо передати йому щось схоже на число то даний варіант конструктора не буде типізовано, але спрацює техніка що зветься SFINAE - Substitution Failure Is Not An Error. Компілятор просто пропутисть цей варіант і буде шукати далі. Якщо йому передано, скажімо, 0 то він зупиниться на конструкторі що приймає у якості аргументу nullptr_t, бо 0 неявно перетворюється на nullptr. Якщо передати йому, скажімо, 123 то отримаємо помилку no matching function for call to '...'. А от якщо передати йому, скажімо, таке: "123" то помилка буде досить... cryptic:
error: ‘* std::_Function_base::_Base_manager::_M_get_pointer [with _Functor = const char*](((const std::_Any_data&)((const std::_Any_data*)__functor)))’ cannot be used as a function
Це тому що у даному випадку не спрацював той „захисний“ другий параметр. Але давайте повернемось до наших баранів. У випадку чесної функції компілятор вибере той шаблонний варіант конструктора і далі шукати не буде. Фішка у тому що і binary, і unary з точки зору типів можна сконструювати з будь-якої функції. Тайпчеккер схаває. І тому компілятор не може визначитись який тип конструювати. Такі справи.
І такий прийом абсолютно виправданий, адже ми не можемо заздалегідь визначити із чого програміст захоче утворити std::function<> - з функції (якого типу?), лямбда-функції (якого типу?) чи функціонального об’єкту (якого типу?).
Але варто взяти, скажімо, gcc-4.8.2, чи clang з libc++ і о диво:
$ g++-4.8.2 -W -Wall -Wextra -pedantic -std=c++0x test.cpp -o test
$ ./test
1
1
$ clang++ -W -Wall -Wextra -pedantic -std=c++0x -stdlib=libc++ test.cpp -o test
$ ./test
1
1
Як же так? Невже вони замість шаблонного конструктора намалювали мільярд всіх можливих „перегрузок“? Давайте подивимось:
template
using __check_func_return_type = __or_, is_convertible<_From, _To>>;
template
using _Invoke = decltype(__callable_functor(std::declval<_Functor&>())(std::declval<_ArgTypes>()...) );
template
using _Callable = __check_func_return_type<_Invoke<_Functor>, _Res>;
template
using _Requires = typename enable_if<_Cond::value, _Tp>::type;
template typename = _Requires<_Callable<_Functor>, void>>
function(_Functor);
_Winnie C++ Colorizer
Сигнатура конструктора змінилась. Тепер у нього лише один параметр, зате з’явився неявний другий шаблонний параметр, який нагадує нам... концепти! Звісно, це ще не справжні концепти, але прекрасна їх емуляція в рамках діючого стандарту. Що ми тут бачимо? Тут знову SFINAE, але на рівні шаблонів. Другий параметр бере тип аргументу, і перевіряє (enable_if), чи можливо привести тип результату застосуання оператора () з аргументами прописаними в сигнатурі std::function<> (_Invoke) до типу результату прописаного у сигнатурі std::function<> (__check_return_type). Тобто перевіряє, чи можна те що йому передали викликати з параметрами прописаними у сигнатурі і отримати тип описаний у сигнатурі. Brilliant!
Я цей трюк одразу полюбив і поклявся взяти собі на озброєння. Але зверніть увагу - як просто потрапити у пастку слабкої системи типів C++!
Другий баг теж відноситься до std::function<>. Із опису нам відомо, що якщо цей функціональний об’єкт сконструйовано пустим (конструктором за замовчуванням, nullptr чи 0) то об’єкт буде неявно приводитись до bool зі значенням false. Якщо ж сконструювати його з нормальною функцією то він буде приводитись до bool зі значенням true. І це дозволяє нам виробляти отакі трюки:
#include
#include
typedef std::function unary;
void maybeCall(unary func)
{
if (func)
std::cout << func(0) << "\n";
}
bool test(int a)
{
return a < 0;
}
int main()
{
int val = 0;
maybeCall(val ? test : 0);
return 0;
}
_Winnie C++ Colorizer
Зверніть увагу на функцію maybeCall: ми у ній перевіряємо чи не пустий нам передали функціональний об’єкт, і тільки у тому разі якщо там дійсно заховане щось пристойне - кличемо його. Здавалося б усе в порядку, але...
$ g++-4.3.6 -W -Wall -Wextra -pedantic -std=c++0x test2.cpp -o test2
$ ./test2
Segmentation fault
$ g++-4.4.7 -W -Wall -Wextra -pedantic -std=c++0x test2.cpp -o test2
$ ./test2
Segmentation fault
$ g++-4.5.4 -W -Wall -Wextra -pedantic -std=c++0x test2.cpp -o test2
$ ./test2
Segmentation fault
$ g++-4.6.4 -W -Wall -Wextra -pedantic -std=c++0x test2.cpp -o test2
$ ./test2
Segmentation fault
$ g++-4.7.3 -W -Wall -Wextra -pedantic -std=c++0x test2.cpp -o test2
$ ./test2
Segmentation fault
Шланг і gcc-4.8.2 справляються нормально:
$ g++-4.8.2 -W -Wall -Wextra -pedantic -std=c++0x test2.cpp -o test2
$ ./test2
$ clang++ -W -Wall -Wextra -pedantic -std=c++0x -stdlib=libc++ test2.cpp -o test2
$ ./test2
І я тут не хочу звинувачувати розробників компіляторів - всі можуть помилятись. Я просто хотів показати на скільки легко помилитись, працюючи з C++.
Вирву із контексту: „... мир окончательно разделится на людей, которые успели выучить C++, пока он еще был простым, и на тех, кто никогда не осилит...“.