Be aware of bugs!

Nov 30, 2013 18:27

Тут шановний 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++, пока он еще был простым, и на тех, кто никогда не осилит...“.

cpp, gcc, clang, робота, баги, програмування

Previous post Next post
Up