А есть ли функция?

В прошлой статье мы рассмотрели, что такое SFINAE и как оно работает, а в настоящей статье мы воспользуемся ранее полученными знаниями. Хотя она и не конкретно по SFINAE, скорее о метапрограммировании в общем. Здесь речь пойдёт о всего одной задаче, которая довольно успешно решается в C++: проверка наличия функции в классе. Почему я выбрал именно эту задачу? Потому что она довольно часто всплывает в вопросах по C++, а также потому, что она достаточно сложна, чтобы можно было показать эволюцию С++ сквозь призму решения этой задачи.

Весь код, использованный в статье, может быть найден в git-хранилище, в ветке has_function_metaprogramming.

Постановка задачи

Итак, задача состоит в следующем: написать метафункцию, которая на вопрос «есть ли заданная функция у класса?», даёт утвердительный или отрицательный ответ. Пускай мы имеем следующий код использования нашей метафункции:

#include <iostream>
#include <algorithm>
#include <vector>
#include <list>
#include "EnableIf.h"
#include "HasFunction.h"

using namespace std;

template<typename Container>
typename EnableIf<HasFunctionSort<Container>::value, void>::type
sort(Container& container)
{
    cout << "Calling member sort function\n";
    container.sort();
}

template<typename Container>
typename EnableIf<!HasFunctionSort<Container>::value, void>::type
sort(Container& container)
{
    cout << "Calling std::sort function\n";
    sort(container.begin(), container.end());
}

int main()
{
    vector<int> vector;
    list<int> list;
    sort(vector);
    sort(list);
}

Хочется сразу отметить, что код выше может быть скомпилирован компилятором поддерживающим только C++03: он не использует никаких новшеств из C++11. Суть кода такова: мы представляем пользователю удобную функцию sort(), которая принимает контейнер в качестве аргумента, что удобнее использования итераторов, как это принято в стандартной библиотеке. Как мы знаем, не все контейнеры в C++ могут быть отсортированы с помощью std::sort (она требует наличия итераторов произвольного доступа). Те контейнеры, что не имеют подходящих итераторов, имеют функцию-член sort(), которая и выполняет сортировку. Поэтому, чтобы охватить эти два случая мы пишем две функции sort(), а на этапе вызова, с помощью SFINAE, выбираем подходящую

Итак, задача ясна, теперь дело за малым: реализовать метафункции EnableIf и HasFunctionSort. Но EnableIf мы уже реализовали в предыдущей статье, и дублировать это решение тут не имеет смысла. Нашей основной задачей станет реализация HasFunctionSort. И начнём мы с реализации оной с помощью C++ образца 2003 года.

Старый метод

Многие знают (а кто-то лишь догадывается) насколько беден был арсенал C++ метапрограммиста до выхода стандарта 11 года. Те, кто не застал написания метапрограмм на старой версии языка, смогут на данном примере в некотором роде прочувствовать, что приходилось делать программистам ранее. Итак, чтобы написать HasFunctionSort нам понадобятся следующие вспомогательные псевдонимы типов:

typedef char False_t;
typedef char True_t[2];

Зачем? Они будут нам нужны, чтобы различать возвращаемое значение функций. Пока оставим это,— их применение мы увидим очень скоро. Главное, что тут стоить усвоить, что эти два типа гарантировано имеют разные размеры: один и 2 char соответственно.

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

Теперь приступим к написанию непосредственно метафункции:

template <typename Type>
struct HasFunctionSort
{
    template<typename T>
    static True_t& test(/*Что сюда вписать?*/);
    static False_t& test(...);
    static const bool value = /*Что сюда вписать?*/;
};

Мы возвращаем ссылку на тип, т.к. массив не может быть использован в качестве возвращаемого значения. Хотя для False_t это и не обязательно, я предпочитаю единообразие в коде, поэтому и добавил ссылку.

Итак, наша метафункция принимает в качестве параметра Type, и возвращает bool, отвечая на вопрос: есть ли у данного типа функция-член sort(). Для определения наличия функции метафункция использует старый добрый SFINAE. Мы уже видели этот «трюк» в предыдущей статье, хотя и немного в другом ключе. Мы имеем 2 функции, одна принимает в качестве аргумента (или аргументов) нечто такое, что помогает однозначно идентифицировать есть ли у типа Type функция-член sort(). Другая функция, может принимать всё, что угодно, но имеет самый низкий приоритет в перегрузке, поэтому она будет вызвана только тогда, когда первая функция вызвана быть не может.

Как вы можете видеть, «правильная» функция возвращает True_t&, а «неправильная» False_t&, что позволяет нам следующим образом получить результат работы нашей метафункции:

static const bool value = sizeof(test(/*Что-то*/)) == sizeof(True_t);

До этого момента всё должно быть ясно: мы вызываем функцию, передавая её что-то, и если при перегрузке выбирается первый вариант, то мы имеем True_t& в качестве возвращаемого значения, иначе False_t&. Осталось только разобраться с чем же нужно вызвать функцию test(). Т.к. наша цель в том, чтобы узнать есть ли у Type функция-член sort(), то логично предположить, что нам нужно передать в качестве аргумента функции test() какую-то информацию, из которой она сможет сделать вывод о наличии или отсутствии такой функции-члена. Естественно, что для определения наличия чего-то у типа, нам нужно сначала узнать этот самый тип. Можно передать объект типа Type по значение или по ссылке, но в таком случае мы накладываем дополнительные ограничения на тип, с которым мы можем работать: он должен будет иметь конструктор по умолчанию.

Мы не должны ограничивать наших пользователей, и поэтому мы пойдём другим путём. Что за сущность в C++ не требует наличия конструктора? Указатель! Поэтому наш вызов test() будет выглядеть следующим образом:

test(static_cast<Type*>(0))

Довольно простой вызов, не так ли?

Теперь нам нужно написать декларацию функции test(). Давайте попробуем сделать это прямо по тому, что у нас есть в вызове:

template<typename T>
static True_t& test(T*);

Сработает ли это? Безусловно. Даст ли это нам то, что нужно? Конечно нет. При такой записи, наша метафункция HasFunctionSort всегда будет возвращать true. Нам нужно исключить функцию с True_t, в качестве типа возвращаемого значения, если у Type нет sort(). Как это сделать? SFINAE, конечно. Но прежде чем приступить к оному, нам нужно понять, что же за функцию sort() мы хотим найти.

Как вы все прекрасно знаете, в C++ может быть множество разных функций с одним именем, если они имеет разную сигнатуру. Поэтому, прежде чем приступить к SFINAE нам нужно жёстко задать, как наша функция будет выглядеть. Для этого определим следующий псевдоним в классе HasFunctionSort:

typedef void (Type::*Signature)();

Теперь ясно, функцию какого типа мы ищем, поэтому давайте попробуем дописать нашу функцию test():

template<typename T, Signature = &T::sort>
static True_t& test(T*);

Это выглядит вполне прилично и кажется совершенно правильным: мы задаём в качестве шаблонного параметра, параметр не-тип (non-type template parameter), и присваиваем ему значение по умолчанию &T::sort. Если у T есть такая функция, и её сигнатура совместима с Signature, тогда выражение test(static_cast<Type*>(0)) компилируется нормально, и мы получаем требуемый вызов функции. Если же нет: срабатывает SFINAE и выполняется test(…), возвращая False_t.

Именно такой вариант я и написал первым. Но, к сожалению, C++03 не поддерживает шаблонные параметры со значениями по умолчанию для функций! Хвала Стандарту, с 11 года они поддерживаются, и этот код может быть собран любым современным компилятором. Но нам нужно, чтобы код собирался и в старых реалиях, поэтому нам придётся пойти несколько дальше.

Хотя шаблонные параметры по умолчанию и не были разрешены для функций, они всегда были разрешены для классов, поэтому, добавив ещё один уровень косвенности, мы получаем нужное нам решение:

template<typename T, Signature = &T::sort>
class Dummy;

template<typename T>
static True_t& test(T*, Dummy<T>* = 0);

Тут происходит всё то же самое, что я описал чуть раньше, просто обходится очевидный недостаток старого стандарта, и используется класс для активизации SFINAE: если у T нет подходящей функции-члена sort(), тогда Dummy<T> не может быть выведен, что включает SFINAE и мы получаем требуемый результат. Теперь пора привести полный код метафункции:

typedef char False_t;
typedef char True_t[2];

template <typename Type>
struct HasFunctionSort
{
    typedef void (Type::*Signature)();
    template<typename T, Signature = &T::sort>
    class Dummy;

    template<typename T>
    static True_t& test(T*, Dummy<T>* = 0);
    static False_t& test(...);

    static const bool value = 
        sizeof(test(static_cast<Type*>(0))) == sizeof(True_t);
};

По моему, не так уж много кода и не так он страшен, хотя львиная его доля — это костыли и самопальные типы, появившиеся в следствие отсутствие нормальной поддержки метапрограммирования в стандартной библиотеке C++03. Полный код для этого параграфа может быть найден в этой фиксации.

Современный метод

Посмотрев как писали код в былые времена, предлагаю рассмотреть как та же задача решается сейчас. Для начал перепишем стартовый пример, воспользовавшись новшествами стандартов 11-го и 14-го годов.

#include <iostream>
#include <algorithm>
#include <vector>
#include <list>
#include <type_traits>
#include "HasFunction.h"

using namespace std;

template<typename Container>
enable_if_t<HasFunctionSort_v<Container>>
sort(Container& container)
{
    cout << "Calling member sort function\n";
    container.sort();
}

template<typename Container>
enable_if_t<!HasFunctionSort_v<Container>>
sort(Container& container)
{
    cout << "Calling std::sort function\n";
    sort(begin(container), end(container));
}

int main()
{
    vector<int> vector;
    list<int> list;
    sort(vector);
    sort(list);
}

Код не сильно поменялся. Основным отличием является избавление от кустарного EnableIf и более современное использование вызова метафункции: HasFunctionSort_v — не могу сказать, что это очень красиво, но это то, к чему пришло сообщество, да и это всё равно короче, чем писать ::value.

Теперь переходим непосредственно к HasFunctionSort. Первое, нам больше не нужны True_t и False_t: мы будет использовать стандартные std::true_type и std::false_type. Исходя из этих изменений, у нас изменится и метод получения возвращаемого значения:

static constexpr bool value = 
    std::is_same<decltype(test(std::declval<Type>())),
        std::true_type>::value;

Т.к. std::true_type и std::false_type это «булевы типы», построенные не на разнице размеров, но именно на отличии самих типов, мы больше не можем использовать sizeof. Вместо него мы используем decltype, чтобы получить тип, который вернёт test() и сравниваем его с std::true_type. Хотя запись немного и поменялась, её суть нисколько не изменилась. Дальше, вместо указателя теперь мы используем std::declval: удобная функция, позволяющая описывать наши намерения в более декларативном стиле (declval мы уже разбирали). 

Наконец, напишем функцию test():

template<typename T>
static auto test(T&&) -> 
    decltype(std::declval<T>().sort(), std::true_type{});
static std::false_type test(...);

И всё! Никаких больше вспомогательных классов и прочего, теперь мы пользуемся SFINAE для выражений и проблем у нас сразу становится меньше. Первое, мы явно показываем, как бы мы хотели, чтобы выглядел вызов: std::declval<T>().sort(), никаких больше «выкрутасов» с указателем на функцию и прочим: декларативный подход всё больше и больше проникает в C++ код. Код выше должен быть понятен, но на всякий случай кратко поясню: когда в коде встречается вызов test(), компилятор пытается вывести возвращаемый тип первого варианта, для этого он проверяет, может ли быть вызван метод sort() у std::declval<T>() и если да, тогда это выражение может быть скомпилировано. Дальше, согласно правилам оператора запятая, всё выражение возвращает prvalue типа std::true_type и, после завершения работы decltype, в списке кандидатов на перегрузку появляется функция возвращающая std::true_type.

Остался последний штрих, а именно: объявить «псевдоним» метафункции, чтобы привести её к современному стилю:

template <typename T>
constexpr bool HasFunctionSort_v = HasFunctionSort<T>::value;

Конечно, с точки зрения языка это не псевдоним — это шаблон переменной (variable template), но семантически (поведенчески?) это не более чем псевдоним. Понятно, что constexpr это та ещё «переменная», но язык не богат на термины, поэтому приходится жить с тем что есть.

И всё на этом — код для C++14 завершён и может быть найден в этой фиксации.

Современный метод 2.0

В предыдущем параграфе мы рассмотрели метод, который в настоящее время используется повсеместно. Но мы можем сделать ещё лучше с выходом C++17. А можем и без выхода оного. Сейчас вы сами всё увидите.

Итак, для начала нам понадобится метафункция, которая будет принимать переменное количество типов и всегда возвращать заранее известный тип. Давайте назовём её Int_t и пусть она всегда возвращает int:

template<typename...>
struct Int_t
{
    using type = int;
};

template<typename... Ts>
using Int_t = Int<Ts...>::type;

Зачем нам нужна эта функция? Она очень удобна в использовании вместе со SFINAE: мы её вызываем с разными выражениями Int_t<decltype(…), decltype(…), …>::type и она либо даёт нам SFINAE, либо же заранее известный тип. Это очень удобно в использовании и вы наверняка встречались с разными типами вида:

template<typename...>
struct Dummy
{
    //...
};

Которые возвращали какой-то тип, не важно какой (а могли и вообще ничего не возвращать). Отличие нашей функции лишь в том, что она всегда возвращает int и мы знаем это. Это важно и скоро вы увидите почему. Теперь давайте вернёмся к нашей метафункции: в отличии от двух предыдущих методов, мы больше не будем использовать никаких вспомогательных методов test() и прочего, но наша метафункции будет разделена на 2 шаблона: базовый и специализацию. Базовый шаблон будет выглядеть так:

template <typename Type, typename = int>
struct HasFunctionSort : std::false_type {};

Это базовая версия шаблона, которая принимает первым аргументом тип, а второй аргумент у неё по умолчанию int. Хотя количество параметров шаблона у нас выросло, эта метафункция всегда должна вызваться лишь с одним аргументом — отличий в вызове HasFunctionSort не будет. Как вы можете видеть, базовый шаблон наследуется от std::false_type, который даёт нам удобный доступ к ::value равному false.

Теперь посмотрим как будет выглядеть наша специализация, которая будет наследоваться от std::true_type:

template <typename Type>
struct HasFunctionSort<Type,
    Int_t<decltype(std::declval<Type>().sort())>> 
    : std::true_type {};

Всё, функция написана! Изумительно, не правда ли? При вызове нашей метафункции HasFunctionSort, если у типа нет подходящей функции sort() срабатывает SFINAE, и будет выбран базовый вариант, если же функция есть, тогда будет выбрана наша специализация и ::value вернёт true! Такая короткая запись гораздо легче читается, чем те «навороты» с test(), что мы использовали до этого.

Работает тут всё довольно просто: при инстанциации шаблона (читай при вызове нашей метафункции) компилятор видит наш общий шаблон и его частный случай. Прежде чем использовать общий шаблон, он должен убедиться, что ни один частный случай не подходит. Поэтому, имея вызов вида HasFunctionSort<TypeWithoutSort>::value, происходит примерно следующее: компилятор проверяет специализацию шаблона, используя первым типом TypeWithoutSort. Затем ему необходимо вычислить второй тип, который в нашей специализации всегда int, но только при условии, что этот тип вообще подходит и выражение может быть скомпилировано. Но т.к. у нашего типа нет sort() (что отражено в его имени) мы получаем SFINAE, и наша специализация шаблона уходит из рассмотрения — остаётся лишь общий случай.

С другой стороны, если у нас есть какой-то такой вызов: HasFunctionSort<TypeWithSort>::value, тогда мы получаем подходящую специализацию HasFunctionSort<TypeWithSort, int> и ::value будет равно true. Здесь и кроется основная причина зачем нам нужен был Int_t: т.к. нам нужно SFINAE в специализации, то нам некуда деваться — нам нужен второй аргумент, над которым мы и будем проводить манипуляции. Более того, второй аргумент шаблона должен иметь значение по умолчанию, иначе вызов метафункции HasFunctionSort с одним аргументом не будет работать. Всё это приводит нас к тому, что общий вид шаблона должен быть написан именно так и никак иначе. В то же время, все мы помним, что специализация должна быть одним из типов, который входит в подмножество всех типов заданных общим шаблоном, а зафиксировав второй тип параметром по умолчанию, наша специализация всегда должна иметь вид: HasFunctionSort<AnyType, int>. Если вторым параметром будет что-либо отличное от int, тогда этот тип перестанет быть специализацией!

Эта интересная техника была впервые представлена широкой публике Уолтером Брауном (Walter E. Brown) на CppCon2014 во второй части его выступления. Уолтер отличный докладчик, и я рекомендую к просмотру как уже упомянутую вторую, так и первую части замечательно доклада посвящённого метапрограммированию. Поэтому если вы понимаете английскую речь и ещё не видели этого видео — смотрите обязательно.

Это всё очень интересно, но причём тут C++17, тут же нет ничего нового! Это действительно так, всё, что описано выше, не использует ничего такого, чего нельзя было бы сделать в C++14. Так что давайте разбираться причём тут C++17. Первое, мы не хотим такого громоздкого определения нашей метафункции Int_t, куда проще написать так:

template<typename...>
using Int_t = int;

Изящно и лаконично, не правда ли? К сожалению, C++14 не содержит никаких указаний, как компилятору стоит обрабатывать аргументы шаблона, которые не используются при объявлении подобных псевдонимов. Т.е. стандарт не гарантирует, что на таких аргументах сработает SFINAE (а именно это нам и нужно). Из-за этого мы не можем использовать такой код в C++14 и надеяться на кроссплатформенность. В C++17 этот недостаток устранён, и теперь этот код должен работать одинаково на всех компиляторах, поддерживающих C++17: т.е. отныне SFINAE гарантировано для неиспользуемых аргументов.

Но на этом нововведения C++17 не заканчиваются, мы получаем ещё и std;:void_t, который находится в type_traits. Его использование идентично тому, как мы использовали Int_t ранее, только теперь с типом void. Не самое удачное название, но именно с такого имени всё началось и, видимо, не придумав ничего более интересного это имя решили оставить. Таким образом, кода нашей метафункции под стандарт C++17 может быть переписан следующим образом:

template <typename Type>
struct HasFunctionSort<Type,
    std::void_t<decltype(std::declval<Type>().sort())>> 
    : std::true_type {};

И всё, никаких дополнительных средств нам не нужно, всё уже есть в стандартной библиотеке! Курс на упрощение метапрограммирования в С++, взятый в 11 году, продолжает нас радовать в каждой новой редакции. Весь код для C++17 может быть найден в этой фиксации.

Тем, кому интересно почитать про std::void_t больше, рекомендую обратиться к оригинальному предложению.

По дороге к концепциям

Что собой представляет HasFunctionSort? По сути, это проверка некого типа на соответствие заданному критерию. Можем ли мы пойти дальше и использовать наши наработки для написание обобщённой метафункции? Безусловно, мы можем, иначе зачем бы тут был этот параграф? Итак, давайте начнём с написания общего случая:

template <typename Type, 
    template<typename> class Concept,
    typename = void>
struct CheckConcept : std::false_type {};

Очень похоже на общий случай HasFunctionSort, но с одним дополнительным параметром: наша метафункция CheckConcept принимает первым аргументом тип, а вторым аргументом другую метафцнкцию, которую нужно применить к этому типу — проверить концепцию. Теперь перейдём к написанию специализации, которая после предыдущего параграфа должна быть вам очевидна:

emplate <typename Type, 
    template<typename> class Concept>
struct CheckConcept<Type, 
    Concept, std::void_t<Concept<Type>>
    > : std::true_type {};

Наконец, посмотрим как же применяется эта метафункция на примере нашей HasFunctionSort. Сначала объявляем нужную нам концепцию:

template <typename T>
using HasMemberSortConcept_t = decltype(std::declval<T>().sort());

Здесь мы задали что конкретно мы хотели бы проверить, и это уже можно использовать вкупе с CheckConcept, но наш исходный код использует HasFunctionSort_v, поэтому давайте определим соответствующий «псевдоним»:

template <typename T>
constexpr bool HasFunctionSort_v =
    CheckConcept<T, HasMemberSortConcept_t>::value;

На мой взгляд подход с CheckConcept выглядит несколько лучше и легче в применении. Кроме того, очень легко использовать CheckConcept для других проверок — эта метафункция никак не привязана к проверке на наличие функции-члена.

Полный код этого примера может быть найден в этой фиксации.

Макросы

Хотя макросы и считаются в C++ чем-то второсортным реальность такова, что при написании каких-то обобщённых, библиотечных вещей без них не обойтись. К настоящему моменту мы уже выяснили как нам написать метафункцию для проверки наличия функции у класса, но всё это получается несколько многословным и повторяющимся. Поэтому, как очень часто поступают в таких случаях, мы добавим макрос. Используя наши предыдущие наработки с CheckConcept, мы добавим макрос, который позволит объявлять необходимые метафункции одной строкой. Макрос получается довольно простой: мы берём код определения метафункции HasFunctionSort и заменяем конкретные части на общие, используя склейку лексем:

#define GENERATE_HAS_FUNCTION(name, functionExpression) \
    template <typename T> \
    using HasMember ## name ## Cooncept_t \
        = decltype(std::declval<T>().functionExpression); \
    template <typename T> \
    constexpr bool HasFunction ## name ## _v \
        = CheckConcept<T, HasMember ## name ## Cooncept_t>::value;

И вот как можно создать нашу HasFunctionSort, используя этот макрос:

GENERATE_HAS_FUNCTION(Sort, sort())

И, собственно, всё! Первым аргументом у нас идёт имя, а вторым выражение, которое нужно проверить. Код использования функции остаётся неизменным и весь код может быть найден в этой фиксации.

Итак, мы написали различные варианты реализации проверки наличия функции у класса через шаблоны, привели это к некоторому обобщённому коду и написали макрос, который позволяет максимально упростить генерацию наших метафункций, что ещё можно сделать? Правильно, можно (и нужно!) выкинуть все наши наработки и воспользоваться готовым решением, которое может быть найдено в Boost.TTI:

BOOST_TTI_HAS_MEMBER_FUNCTION(sort)
template<typename T>
constexpr bool HasFunctionSort_v
    = has_member_function_sort<T, void>::value;

Чуть более многословно, чем наше конечное решение, но всё равно довольно неплохо. Тем более, что это уже есть готовое в существующей библиотеке — бери и пользуйся. Это не говоря уже о том, что наша реализация может не покрывать какие-то случаи (это простая реализация в рамках статьи в блог, а не решение на все случаи жизни). Код с использование boost находится в этой фиксации.

Итог

Рассмотрев различные способы написания метафункции определения наличия функции в классе, я совершил «демарш» и отправил всех использовать boost. Зачем тогда эта статья, зачем вообще разбираться в этих тонкостях когда уже всё написано до нас? Дело в том, что целью и сутью этой статьи является продолжение знакомства читателей с метапрограммированием, которое всё больше занимает умы программистов на C++. Если вы активно следите за тем, что публикуют другие авторы, то могли заметить, что очень много статей посвящено именно метапрограммированию. Зачастую эти статьи носят весьма «натянутый» характер, и авторы выкладывают решения либо очень узконаправленных проблем, либо же решения, которые уже есть в составе популярных библиотек.

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

Всё это нужно для того, чтобы показать на что способен современный C++ и как вы можете использовать его в своих проектах. Ведь ни одна библиотека не способна покрыть все нужды, а видя те кирпичики, из которых строятся библиотечные, общие решения, вы, в дальнейшем, сможете применять это для свих узкоспециализированных вещей. Поэтому, я рекомендую заняться и попробовать метапрограммирование самостоятельно, а не только использовать готовые решения. Даже если вы и не будете писать своих метафункций в будущем, существующие решения перестанут быть для вас магией, а это, в свою очередь, позволит писать программы более уверенно.

Что делать тем, кто хочет расширить свой кругозор касательно метапрограммирования? Помимо уже упомянутого Boost.TTI, я рекомендую посмотреть в сторону Boost.Hana, а также почитать комментарии к предыдущей статье, там вы найдёте упоминание пары интересных книг.