Void me

в 17:02, , рубрики: void, ненормальное программирование, тесты
Void me - 1

void в плюсах довольно забавная штука. Мы можем привести к void почти любой тип, завести указатель с типомvoid*, который может адресовать что угодно. Еще можем сделать функцию с возвращаемым типом void , которая ничего не возвращает. Объявление функции типа void f(void) будет просто функцией без аргументов. Но вот иметь объекты типа void или написать что-то вроде void& не можем. Это немного странно, но не настолько, чтобы вызывать у вас бессонные ночи, пока вы начинаете ловить странные баги, когда void вообще не void.

Проблема возникла где не ждали, а именно на проекте немного обновили бенчмарк фреймворк, казалось что такого может случиться на выполнении тестов?

"Ничего хорошего не случится" - сказал техлид и в пятницу вечером залил, в обход этих самых тестов, новый фреймворк. А сам укатил на какую-то конференцию.


"Приехали, у нас лапки" - сказали QA team в понедельник, когда ни один из тестов не запустился. Здесь и далее gtl это namespace для кастомной реализации std в движке XXX, от game templates library, почти полностью совместимый по интерфейсам с std/eastl. A код был примерно таким:

template <typename F, typename... Args>
auto test_running_time(pcstr test_name, F&& f, Args&&... args) {
    auto start = gtl::high_resolution_clock::now();
    auto result = gtl::invoke(gtl::forward<F>(f), gtl::forward<Args>(args)...);
    auto end = gtl::high_resolution_clock::now();
    
    gtl::duration<double> diff = end - start;
    test_printf("Test %s spent time %ds n", test_name, diff.count());
    return result;
}

Здесь нет, вернее не было, ошибок. Код делает то, что нам нужно, а именно берет два временных отсчёта до и после вызова функции, получает разницу в секундах, пишет её куда-то во внутренние логи и возвращает результат обратно в вызвавшую функцию. И это работало для всех случаев, пока не обновили google benchmark. После обновления сломались функции, которые возвращают void — для них компилятор выдаёт ошибку, указывая на объявление переменной result.

траля-ля-ля, две простыни логов с ворнингами в шаблонах и вот она ошибка
...
error: 'void result' has incomplete type
     // тут еще много текста и наконец
     auto result = gtl::invoke(/* ... */)
          ^~~~~~

"Да ну вас нафик, какие ошибки с войдом" - говорите вы и лезете смотреть, что же там такого поломалось. А просто версия фреймворка поломалась, т.е. обновилась, и придется теперь с этим жить. Сначала попробуем в лоб - просто ходим по тестам и перегружаем test_running_time() для тех функций, которые возвращают void, дублируем все тело функции, в надежде что рабочий день скоро закончится. Но проблема в том, что тестов у нас больше 8к, и около десяти процентов из них сломаны. Это раздражает и после 10 замены вы понимаете, что Сизиф катит этот камень куда-то не туда. И ладно бы с копипастой и временем, но представьте что вам еще и ревью защищать, а там копипасту ох как не любят, так что в целом этот путь полностью провальный — дублировать каждый шаблон функции опасно для ментального здоровья ваших коллег.

Void me - 2

Немного поразмыслив над проблемой можно прийти к другому решению, чтобы вытащить тип результата из выражения, и сразу возвращать его без промежуточного хранения.

template <typename F, typename... Args>
auto test_running_time(pcstr test_name, F&& f, Args&&... args){
    auto start = gtl::high_resolution_clock::now();
    SCOPE_EXIT{
      auto result = gtl::invoke(gtl::forward<F>(f), gtl::forward<Args>(args)...);
      auto end = gtl::high_resolution_clock::now();
      gtl::duration<float> diff = end - start;
      test_printf("Test %s spent time %ds n", test_name, diff.count());
    }
    
    return gtl::invoke(gtl::forward<F>(f), gtl::forward<Args>(args)...);;
}

Как ни странно это сработало. Но если подумать, то никаких секретов тут нет, мы просто воспользовались деструктором локальной переменной, которая будет вызвана сразу после выхода из функции, т.е. это не нарушает обычный поток выполнения тестов, просто выглядит немного странно, при этом выполняет возложенную функцию на этот участок кода - измеряет время выполнения теста. Компилятор не смущает, что мы возвращаем объект типа void, и хотя объекта типа void не существует, в компиляторе есть особое правильно на этот случай, которое позволяет возвращать void как результат выполнения функции. Иначе бы не работала половина стандартной библитеки.

Это почти сработало, почти - потому что с полтинник тестов (но это уже была победа, всего пять десятков, а не восемь сотен) все равно не могли заинстанситься компилятором, и даже вернувшийся с конфУренции техлид разводил руками. Грустно посмотрев на это дело, закоментили эти тесты, чтобы не останавливать работу QA команды, пообщав им скоро это дело починить.

Закончился спринт...

Оставшиеся тесты не собирались - это мучало лида команды QA, он мучал вопросами своих подопечных, а те в свою очередь плакались разработчикам и обещали наловить багов побольше да позаковыристей, если последние эти тесты все таки починят.

Так прошло пару недель, QA становились все более раздражительные, а проходя в курилку выразительно поглядывали на топор, который стоит перед дверью в отдел QA, как символ их незаменимого труда, видимо, чтобы мотивировать таким образом погромистов выполнить данное обещание.

Void me - 3

В итоге мы с коллегами подумали и сделали тип, который бы заменил void и имел схожий функционал и который бы можно свободно подставлять там где обычный void не справлялся. Назвали незамысловато boid, и пусть он значит немного другое, но по смыслу (шарик, чтото мелкое, что можно вернуть из функции) очень даже подходит. Пустой тип, который можно сконструировать из чего угодно. К нему идут две вспомогаетльных обертки, чтобы преобразовывать между собой, где это необходимо. Там где, компилятор не осилил возвратить void — теперь возвращаетсяboid , потому что он является настоящим объектным типом, который нормально умеет создаваться.

struct boid {
    boid() = default;
    boid(boid const&) = default;
    boid(boid&&) = default;
    boid& operator=(boid const&) = default;
    boid& operator=(boid&&) = default;
    
    template <typename Arg, typename... Args,
        gtl::enable_if_t<!gtl::is_base_of_v<boid, gtl::decay_t<Arg>>, int> = 0>
    explicit boid(Arg&&, Args&&...) { }
};

template <typename T>
using wrap_boid_t = gtl::conditional_t<gtl::is_void_v<T>, boid, T>;

template <typename T>
using unwrap_boid_t = gtl::conditional_t<gtl::is_same_v<gtl::decay_t<T>, boid>, void, T>;

Переписываем немного код функции, чтобы она умела работать с новым boid типом, для этого понадобится еще две обертки, чтобы компилятор умел различать эти типы.

template <typename F, typename ...Args,
    typename Result = gtl::invoke_result_t<F, Args...>,
    gtl::enable_if_t<!gtl::is_void_v<Result>, int> = 0>
Result invoke_boid(F&& f, Args&& ...args) {
    return gtl::invoke(gtl::forward<F>(f), gtl::forward<Args>(args)...);
}

template <typename F, typename ...Args,
    typename Result = gtl::invoke_result_t<F, Args...>,
    gtl::enable_if_t<gtl::is_void_v<Result>, int> = 0>
boid invoke_boid(F&& f, Args&& ...args) {
    gtl::invoke(gtl::forward<F>(f), gtl::forward<Args>(args)...);
    return Void();
}

У внимательного хабрачитателя вполне может возникнуть вопрос, почему бы просто не поменять возвращаемый тип в оставшихся тестах? Ответ простой - это не всегда было возможно и сложившиеся зависимости в тестах приводили к новым изменениям, которые приводили к другим изменения, и так далее. Так что вооружившись новый boid'ом остается переписать немного исходный пример, и вуаля сломанные тесты вернулись в строй:

template <typename F, typename... Args>
auto test_running_time(pcstr test_name, F&& f, Args&&... args)
{
    auto start = gtl::high_resolution_clock::now();
    // gtl::invoke -> invoke_void
    auto result = invoke_boid(gtl::forward<F>(f), gtl::forward<Args>(args)...);
    auto end = gtl::high_resolution_clock::now();
    
    gtl::duration<double> diff = end - start;
    test_printf("Test %s spent time %ds n", test_name, diff.count());
    return result;
}

Заключение

Всё это ненормальное программирование, с перегрузкой войда поднимает вопрос — а чегоvoid вообще такой странный? Техлид говорил - «Потому что C», всегда ведь проще сказать, что виноват старый язык программирования, чем новомодный стандарт. Я не знаю историю появления типа void в языке, но было бы интересно узнать, возможно кто-нибудь в коментариях и напишет.

Думаюvoid не является объектным типом не просто так, а чтобы нельзя было передать экземпляр void в функцию, как тогда этот аргумент обрабатывать? Вопрос конечно интересный.

void foo(int a, void b, float c);

Автор: dalerank

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js