Be aware of bugs!
Nov. 30th, 2013 06:27 pmТут шановний
sharpc лякає людей синтаксисом C++, а мені згадались два баги компілятора (а точніше реалізації стандартної бібліотеки), на які ми на роботі наштовхнулись буквально минулого тижня.
Почнемо з простого, зовсім невинного і в чомусь навіть примітивного коду:
Люди ризикові, які живуть на 10 секунд у майбутньому і користуються останніми версіями компіляторів цього разу у виграші. Вони при компіляції цього коду навіть нічого і не відчують. А от пересічні громадяни, такі як ви і я, що користуються стабільними версіями компіляторів, обов’язково отримають по лобі підводними граблями:
Ну добре, 4.4 це взагалі моветон. Тут навіть лямбд немає! Але зверніть увагу на перше повідомлення про помилку — воно вам не здається дивним? Для більшого ефекту спробуємо інші версії:
До речі, на роботі у нас 4.6.3.
Компілятор намагається сказати нам що він не знає який варіант функції call вибрати. І це дивно, бо сигнатура функції що передається їй як параметр чітко визначена. Давайте розберемось, що відбувається під час виклику функції call. По-перше, що таке std::function<>: це „функціональний об’єкт“ (прості C++-ники скажуть „функтор“, але ми-то з вами знаємо...) — об’єкт що містить у собі метод operator(), який дозволяє „викликати“ такий об’єкт на виконання як звичайну функцію. Таким макаром у C++ реалізована підтримка функцій як first-class citizens. Будь-яку функцію можна „упакувати“ у такий функціональний об’єкт, включаючи лямбда-функції, — головне щоб сигнатура співпадала.
Таким чином функція call очікує на вхід параметр типу „функціональний об’єкт“, а ми підсовуємо їй дещо інше. У першому випадку це вказівник на звичайну функцію, у другому — лямбда-функцію. До речі, це прекрасний приклад того що лямда-функції мають свій власний тип, а не std::function<>. Щоб викликати call з таким аргументом треба його перетворити на той тип, який хоче call. Тобто у цьому місці викликається конструктор std::function<>. І схоже що компілятор вважає можливим сконструювати обидва типи із унарної функції і бінарної лямбди! Але як таке можливе? Давайте поглянемо на цей конструктор:
Він досить таки простий: це шаблонна функція, яка приймає на вхід аргумент шаблонного типу. А це значить що конструктор може бути викликаний з чим завгодно. З яким завгодно аргументом. Будь якого типу.
Ну, насправді, це не зовсім правда. Якщо ви підсунете йому, скажімо, 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<_Functor>::_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++ і о диво:
Як же так? Невже вони замість шаблонного конструктора намалювали мільярд всіх можливих „перегрузок“? Давайте подивимось:
Сигнатура конструктора змінилась. Тепер у нього лише один параметр, зате з’явився неявний другий шаблонний параметр, який нагадує нам... концепти! Звісно, це ще не справжні концепти, але прекрасна їх емуляція в рамках діючого стандарту. Що ми тут бачимо? Тут знову SFINAE, але на рівні шаблонів. Другий параметр бере тип аргументу, і перевіряє (enable_if), чи можливо привести тип результату застосуання оператора () з аргументами прописаними в сигнатурі std::function<> (_Invoke) до типу результату прописаного у сигнатурі std::function<> (__check_return_type). Тобто перевіряє, чи можна те що йому передали викликати з параметрами прописаними у сигнатурі і отримати тип описаний у сигнатурі. Brilliant!
Я цей трюк одразу полюбив і поклявся взяти собі на озброєння. Але зверніть увагу — як просто потрапити у пастку слабкої системи типів C++!
Другий баг теж відноситься до std::function<>. Із опису нам відомо, що якщо цей функціональний об’єкт сконструйовано пустим (конструктором за замовчуванням, nullptr чи 0) то об’єкт буде неявно приводитись до bool зі значенням false. Якщо ж сконструювати його з нормальною функцією то він буде приводитись до bool зі значенням true. І це дозволяє нам виробляти отакі трюки:
Зверніть увагу на функцію maybeCall: ми у ній перевіряємо чи не пустий нам передали функціональний об’єкт, і тільки у тому разі якщо там дійсно заховане щось пристойне — кличемо його. Здавалося б усе в порядку, але...
Шланг і gcc-4.8.2 справляються нормально:
І я тут не хочу звинувачувати розробників компіляторів — всі можуть помилятись. Я просто хотів показати на скільки легко помилитись, працюючи з C++.
Вирву із контексту: „... мир окончательно разделится на людей, которые успели выучить C++, пока он еще был простым, и на тех, кто никогда не осилит...“.
Почнемо з простого, зовсім невинного і в чомусь навіть примітивного коду:
#include <functional> #include <iostream> typedef std::function<bool (int)> unary; typedef std::function<bool (int, int)> 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()::<lambda(int)>)’ 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()::<lambda(int)>)’ 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()::<lambda(int)>)’ 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<typename _Functor> function(_Functor __f, typename enable_if<!is_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<_Functor>::_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<typename _From, typename _To> using __check_func_return_type = __or_<is_void<_To>, is_convertible<_From, _To>>; template<typename _Functor> using _Invoke = decltype(__callable_functor(std::declval<_Functor&>())(std::declval<_ArgTypes>()...) ); template<typename _Functor> using _Callable = __check_func_return_type<_Invoke<_Functor>, _Res>; template<typename _Cond, typename _Tp> using _Requires = typename enable_if<_Cond::value, _Tp>::type; template<typename _Functor, 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 <functional> #include <iostream> typedef std::function<bool (int)> 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++, пока он еще был простым, и на тех, кто никогда не осилит...“.