Недавно, работая над новой версией SObjectizer-а, довелось столкнуться задачей контроля в компайл-тайм действий разработчика. Суть была в том, что ранее программист мог делать вызовы вида: Операция receive() требовала набор параметров, для указания которых использовались цепочки методов, вроде показанных выше from(ch).empty_timeout(150ms) или from(ch).handle_n(2).no_wait_on_empty(). При этом вызов методов handle_n()/extract_n(), ограничивающих количество извлекаемых/обрабатываемых сообщений, был необязательным. Поэтому все показанные выше цепочки были корректными. Но в новой версии потребовалось заставить пользователя обязательно явно указывать количество сообщений для извлечения и/или обработки. Т.е. цепочка вида from(ch).empty_timeout(150ms) теперь становилась некорректной. Её следовало заменить на from(ch).handle_all().empty_timeout(150ms). Причем хотелось сделать так, чтобы именно компилятор бил бы программиста по рукам, если программист забыл сделать вызов handle_all(), handle_n() или extract_n(). Может ли C++ помочь в этом? Да. И если кому-то интересно как именно, то милости прошу под кат. Выше была показана функция receive(), параметры для которой задавались посредством цепочки вызовов (также известной как builder pattern). Но в наличии была еще и функция select(), которая получала практически такой же набор параметров: Соответственно, хотелось получить одно решение, которое подходило бы и для select(), и для receive(). Тем более, что сами параметры для select() и receive() уже представлялись в коде так, чтобы избежать copy-and-paste. Но об этом речь зайдет ниже. Итак, задача в том, чтобы пользователь в обязательном порядке вызвал handle_all(), handle_n() или extract_n(). В принципе, этого можно достичь не прибегая к каким-либо сложным решениям. Например, можно было бы ввести дополнительный аргумент для select() и receive(): Либо можно было бы заставить пользователя оформлять вызов receive()/select() по-другому: Но проблема здесь в том, что при переходе на новую версию SObjectizer-а пользователю пришлось бы переделывать свой код. Даже если код в принципе не требовал переделки. Скажем, вот в такой ситуации: А это, на мой взгляд, очень серьезная проблема. Которая заставляет поискать другой способ. И этот способ будет описан ниже. В заголовке статьи был упомянут CRTP. Он же Curiously Recurring Template Pattern (желающие ознакомится с этой интересной, но слегка выносящей мозг техникой, могут начать с этой серии постов в блоге Fluent C++). Упомянут CRTP был потому, что посредством CRTP у нас была реализована работа с параметрами функций receive() и select(). Поскольку львиная доля параметров для receive() и select() была одинаковой, то в коде использовалось что-то вроде вот такого: CRTP здесь пришлось применить чтобы методы-setter-ы, которые были определены в базовом классе, могли возвращать ссылку не на базовый тип, а на производный. Т.е., если бы использовалось не CRTP, а обычное наследование, то мы могли бы написать разве что вот так: Но такой примитивный механизм не позволит нам использовать тот самый builder pattern, поскольку: не скомпилируется. Метод handle_n() возвратит ссылку на bulk_processing_params_t, а там метод receive_payload() еще не определен. А вот с CRTP у нас проблем с builder pattern нет. Итоговое решение состоит в том, чтобы финальные типы, вроде receive_processing_params_t и select_processing_params_t, сами так же стали шаблонными типами. Чтобы они параметризовались скаляром следующего вида: И чтобы финальный тип мог конвертироваться из T<msg_count_status_t::undefined> в T<msg_count_status_t::defined>. Это позволит, например, в функции receive() получать receive_processing_params_t и проверять значение Status в компайл-тайм. Что-то вроде:
 В общем-то, все просто, как обычно: взять и сделать ;) Давайте посмотрим на минимальном примере, отвязанном от специфики SObjectizer-а, как это выглядит. Итак, у нас уже есть тип, который определяет, задано ли ограничение на количество сообщений или не задано: Далее нам потребуется структура, в которой будут храниться все общие параметры: Вообще не суть что за содержимое будет у basic_data_t. Для примера подойдет тот минимальный набор полей, который показан выше. Применительно к basic_data_t важно то, что для конкретных операций (будь то receive(), select() или что-то еще) будет создаваться свой собственный конкретный тип, наследующий basic_data_t. Например, для receive() в нашем абстрагированном примере это будет следующая структура: Будем считать, что структура basic_data_t и её наследники сложностей не вызывают. Поэтому перейдем к более сложным частям решения. Сейчас нам потребуется обертка вокруг basic_data_t, которая будет предоставлять методы-getter-ы. Это будет шаблонный класс следующего вида: Шаблонным этот класс является для того, чтобы он мог содержать любого наследника от basic_data_t, хотя методы-getter-ы он реализует только для тех полей, которые находятся в basic_data_t. Прежде чем мы перейдем к еще более сложным частям решения, следует обратить внимание на метод data() в basic_data_holder_t. Это важный метод и мы столкнемся с ним позже. Теперь мы можем перейти к ключевому шаблонному классу, который может выглядеть довольно-таки страшно для людей, не сильно посвященных в современный C++: Вот этот вот basic_params_t — это и есть основной CRTP-шный шаблон. Только теперь он параметризуется двумя параметрами. Первый параметр — это тип данных, который должен содержаться внутри. Например, receive_specific_data_t или select_specific_data_t. Второй параметр — это привычный для CRTP тип наследника. Он используется в методе self_reference() для получения ссылки на производный тип. Ключевой момент в реализации шаблона basic_params_t — это его метод clone_as_defined(). Данный метод рассчитывает на то, что наследник реализует у себя метод clone_if_necessary(). А этот clone_if_necessary() как раз и предназначен для того, чтобы можно было трансформировать объект T<msg_count_status_t::undefined> в объект T<msg_count_status_t::defined>. И такая трансформация инициируется в методах-setter-ах handle_all(), handle_n() и extract_n(). Причем можно обратить внимание на то, что clone_as_defined(), handle_all(), handle_n() и extract_n() определяют тип своего возвращаемого значения как decltype(auto). В этом кроется еще один фокус, о котором мы скоро поговорим. Сейчас мы уже можем взглянуть на один из финальных типов, для которого это все затевалось: Первое, на что следует обратить здесь внимание, — это на конструктор, который принимает base_type::data_type. Посредством этого конструктора производится передача текущих значений параметров при трансформации из T<msg_count_status_t::undefined> в T<msg_count_status_t::defined>. По большому счету, этот receive_specific_params_t представляет из себя что-то вот такое: И как раз упомянутый конструктор receive_specific_params_t позволяет проинициализировать receive_specific_params_t<msg_count_status_t::defined> значениями из receive_specific_params_t<msg_count_status_t::undefined>. Вторая важная штука в receive_specific_params_t — это два метода clone_if_necessary(). Почему их два? И что означает вся эта SFINAE-вская магия в их определении? Сделано два метода clone_if_necessary() для того, чтобы избежать лишних трансформаций. Допустим, программист вызвал метод handle_n() и уже получил receive_specific_params_t<msg_count_status_t::defined>. А потом вызвал extract_n(). Это разрешено, handle_n() и extract_n() задают несколько разные ограничения. Вызов extract_n() также должен дать нам receive_specific_params_t<msg_count_status_t::defined>. Но у нас такой уже есть. Так почему бы не переиспользовать уже существующий? Вот поэтому здесь два метода clone_if_necessary(). Первый будет работать тогда, когда трансформация реально нужна: Компилятор будет выбирать его, например, когда статус меняется с undefined на defined. И возвращать этот метод будет новый объект. И да, в реализации этого метода мы обращаем внимание на вызов data(), который был определен еще в basic_data_holder_t. Второй же метод: будет вызываться когда менять статус не нужно. И этот метод возвращает ссылку на уже существующий объект. Теперь должно стать понятно, почему в basic_params_t для ряда методов тип возвращаемого значения определялся как decltype(auto). Ведь эти методы зависят от того, какая именно версия clone_if_necessary() будет вызвана в производном типе, а там может возвращаться либо объект, либо ссылка… Заранее не предугадаешь. И вот здесь decltype(auto) приходит на помощь. Описанный минималистичный пример преследовал цель максимально простой и понятной демонстрации выбранного решения. Поэтому в нем нет вполне очевидных вещей, которые напрашиваются быть включенными в код. Например, метод basic_data_holder_t::data() возвращает константную ссылку на данные. Что ведет к копированию значений параметров при трансформации T<msg_count_status_t::undefined> в T<msg_count_status_t::defined>. Если копирование параметров является дорогой операций, то следовало бы озадачится move-семантикой и метод data() мог бы иметь следующий вид: Также сейчас в каждый финальный тип (вроде receive_specific_params_t и select_specific_params_t) приходится включать реализации методов clone_if_necessary. Т.е. в этом месте мы все еще пользуемся копипастой. Возможно, здесь также следовало бы что-то придумать, чтобы избежать дублирования однотипного кода. Ну и да, в коде нигде не проставлены noexcept дабы сократить "синтаксический оверхэд" (с). Исходный код обсуждавшегося здесь минималистичного примера можно найти здесь. А поиграться в on-line компиляторе можно, например, здесь (можно закомментировать вызов handle_all() в строке 163 и посмотреть, что получится). Не хочу сказать, что реализованный мной подход единственно правильный. Но, во-первых, альтернативу я видел разве что в копипасте. И, во-вторых, сделать это оказалось совсем не сложно и много времени это, к счастью, не заняло. А вот удары компилятора по рукам очень сильно помогли сразу же, по мере адаптации старых тестов и примеров к новым возможностям свежей версии SObjectizer-а. Так что, как по мне, C++ в очередной раз подтвердил, что он сложен. Но не просто так, а для того, чтобы дать больше возможностей разработчику. Ну и, не удивлюсь, если это все можно было в современном C++ получить еще более простым способом, нежели это получилось у меня. PS. Если кто-то из читателей следит за SObjectizer-ом, то могу сказать, что новая версия 5.6, в которой была существенно нарушена совместимость с веткой 5.5, уже вполне себе задышала. Найти ее можно на BitBucket-е. До релиза еще далеко, но SObjectizer-5.6 уже стал таким, каким задумывался. Можно брать, пробовать и делиться своими впечатлениями. Велосипедостроитель, программист-камикадзе