О шаблонах в С++, чуть сложнее

146afb65362095046986be80593f7dd1.JPG

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

Оглавление

  1. Проблема и предлагаемое решение

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

  3. Простой шаблон

  4. Специализация шаблона функции и перегрузка функций

  5. SFINAE (Substitution Failure Is Not An Error) — если подстановка не сработала, то её можно проигронировать

  6. SFINAE и trailing return type

  7. Пишем makeString () для коллекций

  8. Попытка написать makeString () для строк

  9. Type traits — свойства типов и специализация шаблонов по числовому параметру (by Non-type template parameter)

  10. Что же ты, ____ [компилятор], делаешь…

  11. Библиотека type_traits

  12. Универсальные ссылки и std: forward

  13. Variadic templates — шаблоны с переменным числом параметров

  14. Рекурсивный подход к variadic templates

  15. Подход со сверткой (fold expression) к Variadic templates

  16. Концепты и ограничения (constraints) — синтаксический сахар для SFINAE

  17. Стало ли с концептами лучше?

  18. Итоги

Проблема

В имеющемся std::to_string присутствует несколько недостаков:

  • он написан не нами. Другими словами, во время его написания опыт и море удовольствия были получены кем-то другим.

  • он не расширяем новыми типами. На практике, можно расширить пространство std своими перегрузками и даже шаблонами, но стандарт говорит о неопределенном поведении такого кода. Кроме того, подобные решения могут порождать слишком жаркие споры в курилке.

Предлагаемое решение

Напишем свой вариант makeString с отличными от std::to_string недостатками. Требованиями к нему будут:

  • схожий очевидный интерфейс: makeString(3.14); и makeString(directionVector); должны делать строку из своего аргумента;

  • расширяемость. Невозможно заранее знать, как перевести экземпляр произвольного класса class UserRequest; в строку, но можно сказать, что объект любого класса с методом std::string to_string() const; переводится в строку очевидным образом. Другими словами, для добавления поддержки перевода нового типа в строку, в нем нужно будет реализовать один метод to_string.

  • код писать будем на актуальной версии С++ который можно без проблем получить «из коробки» в актуальной на данный момент Ubuntu 20.04.3 LTS или VS 2019 Community Edition. А для того, чтобы это был чистый C++, попросим компилятор быть с нами построже. Я передам свою просьбу компилятору с помощью CMake-скрипта, и буду надеяться, чтоб абсолютному большинству С++ программистов не составит труда перевести эти руны в свою любимую IDE для своего любимого компилятора.

cmake_minimum_required(VERSION 3.0.0)
project(makeString VERSION 0.1.0)

add_executable(makeString main.cpp)

set(CMAKE_CXX_EXTENSIONS OFF)      # no vendor-specific extensions
set_property(TARGET makeString PROPERTY CXX_STANDARD 20)

if (MSVC)
    target_compile_options(makeString PUBLIC /W4 /WX /Za /permissive-)
else()
    target_compile_options(makeString PUBLIC -Wall -Wextra -pedantic -Werror)
endif()

Опыт и море удовольствия

И так, постановка задачи на языке С++:

#include 
#include "makeString.hpp"

struct A
{
    std::string to_string() const { return "A"; }
};

struct B
{
    int m_i = 0;
    std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
};

int main()
{
    A a;
    B b = {1};

    std::cout << "a: " << makeString(a) << "; b: " << makeString(b);
}

Простой шаблон

Так как мы хотим, чтобы to_string у нас автоматичкски «подхватывался» из объекта пользовательсого типа, нам нужен шаблон. Шаблон — это вещь, на первый взгляд, нехитрая, но если уже здесь возникают сложности, то можно обратиться к указанной выше статье, где достаточно подробно и простым языком описаны основы.

// makeString.hpp
#pragma once
#include 

template 
std::string makeString(const Object& object)
{
    return object.to_string();
}

Собрали, запустили, работает: a: A; b: B{1}

Специализация шаблона функции и перегрузка функций

Хотелось бы иметь возможность написать makeString(3.14) несмотря на то, что у типа double нет метода to_string. К счастью, реализацию std::to_string у нас никто не забирал, и из прошлой статьи мы уже знаем про специализацию шаблонов функций.

int main()
{
	  // ... 
    std::cout << "a: " << makeString(a) << "; b: " << makeString(b) 
              << "; pi: " << makeString(3.14)
              << std::endl;
}
// makeString.hpp
...
// [build] ../makeString.hpp:13:24: error: 
// template-id ‘makeString<>’ for ‘std::string makeString(double)’ 
// does not match any template declaration
template <> std::string makeString(double d) { return std::to_string(d); }

Первый блин — комом. Действительно, компилятор не понимает, какой именно из шаблонов, принимающих константную ссылку, мы хотим специализировать этой сигнатурой. Освежив свои познания полезной статьей на cppreference, мы ему поможем, и понадеемся, что оптимизатор «подчистит» за нами передачу примитивных типов по ссылке. Так же вспомним, что кроме double у нас есть float и еще 7 других примитивных типов, для которых есть своя перегрузка std::to_string:

// makeString.hpp
#pragma once
#include 

template 
std::string makeString(const Object &object)
{
    return object.to_string();
}

template <> std::string makeString(const double& d) { return std::to_string(d); }
template <> std::string makeString(const float& f)  { return std::to_string(f); }
template <> std::string makeString(const int& i)    { return std::to_string(i); }
// ... 6 more specializations ...

Есть, однако, и альтернативный вариант. Использование шаблонов не отменяет использование перегрузки функций, поэтому нет ничего плохого в том, чтобы просто добавить перегрузок, краем глаза поглядывая в раздел Function template overloading в замечательной статье на сайте, имя которого я не буду указывать, т.к. они не платят мне за SEO. Статья действительно хороша, можно плодотворно провести за её чтением не одни сутки.

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

// overloading instead of specializations
std::string makeString(double d) { return std::to_string(d); }
std::string makeString(float f)  { return std::to_string(f); }
std::string makeString(int i)    { return std::to_string(i); }
// ... 6 more overloads ...

Компилируем, запускаем, заработало: a: A; b: B{1}; pi: 3.140000

Где-то посередине написания этой копипасты из перегрузок, разработчика может посетить мысль о том, что копипасту можно заменить её на еще один шаблон. Попробуем:

// makeString.hpp
#pragma once
#include 

template 
std::string makeString(const Object& object)
{
    return object.to_string();
}

/*
  [build] ../main.cpp: In function ‘int main()’:
  [build] ../main.cpp:20:39: error: call of overloaded ‘makeString(A&)’ is ambiguous
  [build]    20 |     std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
*/
template 
std::string makeString(Numeric value)
{
    return std::to_string(value);
}

И ведь действительно: есть вызов makeString(a), где a в месте вызова имеет тип A& (lvalue reference to A), и компилятор не понимает, какому из шаблонов надо эти вызовы сопоставить, т.к. синтаксически верна подстановка в оба объявления (declaration) шаблонной функции, а в определение (definition) шаблонной функции он во время подстановки лезть не должен и не будет.

В этот момент может появиться желание «выключить» одну из сигнатур из разрешения перегрузок. Желание есть, в нормы современной морали оно вписывается, значит не будем ему противиться, ведь у нас есть…

SFINAE (Substitution Failure Is Not An Error) — если подстановка не сработала, то её можно проигронировать

Кстати, у них (у тех, кто не платит мне за SEO), SFINAE тоже есть. Здесь же будет простое описание на случай, когда хочется не читать, а писать: если в результате подстановки шаблонных параметров в объявление шаблонной функции получается синтаксически неверная конструкция, то это объявление будет проигнорировано компилятором без сообщения об ошибке. То же самое справедливо и для шаблонных классов и переменных.

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

// makeString.hpp
#pragma once
#include 
#include   // for std::declval

// (1)
template ().to_string())>
std::string makeString(const Object& object)
{
    return object.to_string();
}

namespace Impl { bool acceptNumber(int); }

// (2)
template 
std::string makeString(Numeric value, 
                       decltype(Impl::acceptNumber(value))* = nullptr)
{
    return std::to_string(value);
}

Постараюсь расшифровать приведенные выше руны:

  1. Первая функция имеет два шаблонных параметра: тип объекта, и безымянный неиспользуемый параметр того же типа, который вернет вызов Object::to_string() для сферического экземпляра Object в вакууме. std::declval в данном случае — это замена конструктора по умолчанию, т.к. у типа Object такого конструктора может не быть.

    Для типов A и B данная конструкция успешно распарсится компилятором в момент вызова makeString, но если первый аргумент типа double, компилятор не сможет вывести тип std::declval().to_string() и проигнорирует это определение.

  2. Вторая функция принимает два параметра, второй из них — это указатель на тот же тип, который вернет вызов Impl::acceptNumber(value), а так, как у нас acceptNumber объявлен только для int и всех типов, неявно преобразуемых к нему, то попытка подставить туда struct A или struct B провалится и объявление будет проигнорировано. double же неявно приведется к int, компилятор выведет тип decltype(Impl::acceptNumber(value)) и подстановка успешно сработает.

  3. Запустим, убедимся, что код работает, и попробуем упростить его.

    SFINAE и Trailing return type

    Альтернативой параметрам-пустышкам шаблона и таким же параметрам функции может быть auto для возвращаемого значения. Одно из преимуществ такого решения в том, что ни шаблону, ни методу не добавляются неявные параметры. К слову, это мой любимый вариант использования SFINAE без смс и type_traits в рамках С++17:

    // makeString.hpp
    #pragma once
    #include 
    
    // (3)
    template 
    auto makeString(const Object &object) -> decltype(object.to_string())
    {
        return object.to_string();
    }
    
    // (4)
    template 
    auto makeString(Numeric value) -> decltype(std::to_string(value))
    {
        return std::to_string(value);
    }

    В примере выше auto заставит компилятор выводить тип возвращаемого значения, а подсказка вида -> decltype(...)не даст ему этого сделать если:

    1. Не удается вычислить тип, который вернет object.to_string();

    2. Не удается вычислить тип, который вернет std::to_string(object);

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

    Пишем makeString () для коллекций

    Итак, расширим задачу:

        // ...
    
        const std::vector xs = {1, 2, 3};
        const std::set ys = {4, 5, 6};
        const double zs[] = {7, 8, 9};
    
        std::cout << "a: " << makeString(a) << "; b: " << makeString(b) 
                  << "; pi: " << makeString(3.14) << std::endl
                  << "xs: " << makeString(xs) << "; ys: " << makeString(ys) 
                  << "; zs: " << makeString(zs)
                  << std::endl;

    Компилятор заботливо напишет нам о том, что no matching function for call to ‘makeString(const std::vector&)’, т.к. оба имеющихся шаблона не прошли подстановку, а значит нужно написать третий.

    Определимся с решением: у нас есть три разных типа: vector, set, double[]. Между ними должно быть что-то общее.

    С моей точки зрения, по всем трем можно итерировать. Вооружимся функцией std::begin() и поверхностными знаниями о SFINAE, чтобы дописать в makeString.hpp теперь уже очевидный метод, возвращаемым значением которого будет тот же тип, который вернет вызов makeString для результата разыменования вызова std::begin для его аргумента:

    template 
    auto makeString(const Iterable& iterable) 
        -> decltype(makeString(*std::begin(iterable)))
    {
    	std::string result;
    	for (const auto& i : iterable)
    	{
    		if (!result.empty())
    			result += ';';
    		result += makeString(i);
    	}
    
    	return result;
    }

    Скомпилируем, запустим, возрадуемся:

    a: A; b: B{1}; pi: 3.140000
    xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000

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

    Попытка написать makeString () для строк

    Раз уж мы делаем std::string из примитивных типов, пользовательских классов и самых разных коллекций, почему бы не сделать строку из С-строки или другой строки?

    Допишем новую задачу в int main() и попробуем:

    int main()
    {
        A a;
        B b = {1};
    
        const std::vector xs = {1, 2, 3};
        const std::set ys = {4, 5, 6};
        const double zs[] = {7, 8, 9};
    
        std::cout << "a: " << makeString(a) << "; b: " << makeString(b) 
                  << "; pi: " << makeString(3.14) << std::endl
                  << "xs: " << makeString(xs) << "; ys: " << makeString(ys) 
                  << "; zs: " << makeString(zs)
                  << std::endl;
    
        std::cout << makeString("Hello, ") 
                  << makeString(std::string_view("world")) 
                  << makeString(std::string("!!1")) 
                  << std::endl;
    }

    Скомпилируем, запустим, работает! Но не так, как хотелось бы:

    a: A; b: B{1}; pi: 3.140000
    xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
    72;101;108;108;111;44;32;0119;111;114;108;10033;33;49

    Оказывается, что наша строка подходит под makeString(const Iterable& iterable). Кроме того, тип char — целый, функции std::to_string(char) в стандартной библиотеке нет, а поэтому, как мы все наверняка читали в разделе Integer promotions на одном интересном сайте, char «получает повышение» до int и наш код радостно печатает три пачки целых чисел вместо строк.

    Type traits — свойства типов и специализация шаблонов по числовому параметру (by Non-type template parameter)

    Итак, нам нужно ограничить применение шаблона makeString(const Iterable& iterable)только теми типами, которые не строки, и дописать еще одну реализацию для строк. Задача получения свойств типа на этапе компиляции уже решалась до нас, и в общем виде она называется «type traits».

    Пусть у нас ничто не строка, кроме std::string, std::string_view и char*. Выразим это условие через С++ код:

    namespace Impl
    {
    template  inline constexpr bool isString  = false;
    template <> inline constexpr bool isString       = true;
    template <> inline constexpr bool isString             = true;
    template <> inline constexpr bool isString       = true;
    template <> inline constexpr bool isString = true;
    template <> inline constexpr bool isString  = true;
    }

    Теперь Imlp::isString будет false для всех типов, кроме тех, для которых есть специализация, возвращающая true. Дело за малым: нужно сделать так, чтобы подстановка в makeString(const Iterable& iterable)не проходила для случаев, когда IsString == true.

    Вспомнив, что шаблонным параметром может быть не только произвольный тип, но и значение фиксированного типа, например bool, объявим шаблонный класс, который в общем виде будет пустым, а в специализации для true будет иметь нужный нам параметр:

    template  struct enable_if;
    template  struct enable_if { using type = T; };
    // syntax sugar: 'enable_if_v' is equivalent of 'typename enable_if::type'
    template  using enable_if_t = typename enable_if::type;

    Теперь использование шаблонного типа enable_if::type возможно, в то время, как enable_if::typeне определен, и вызовет ошибку подстановки (что, как нам известно, «is not an error»). Чтобы немного сократить запись, можно определить псевдоним enable_if_t.

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

    Собираем код в кучу, избавляемся от плагиата, компилируем и запускаем:

    // makeString.hpp
    #pragma once
    #include 
    #include 
    
    namespace Impl
    {
    template  inline constexpr bool isString  = false;
    template <> inline constexpr bool isString       = true;
    template <> inline constexpr bool isString             = true;
    template <> inline constexpr bool isString       = true;
    template <> inline constexpr bool isString = true;
    template <> inline constexpr bool isString  = true;
    }
    
    template 
    auto makeString(const Object& object) -> decltype(object.to_string())
    {
        return object.to_string();
    }
    
    template 
    auto makeString(Numeric value) -> decltype(std::to_string(value))
    {
        return std::to_string(value);
    }
    
    template 
    auto makeString(const Iterable& iterable) 
        -> std::enable_if_t, 
                            decltype(makeString(*std::begin(iterable)))>
    {
        std::string result;
        for (const auto &i : iterable)
        {
            if (!result.empty())
                result += ';';
            result += makeString(i);
        }
    
        return result;
    }
    
    template 
    auto makeString(const String& s)
        -> std::enable_if_t, std::string>
    {
        return std::string(s);
    }
    

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

    a: A; b: B{1}; pi: 3.140000
    xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
    72;101;108;108;111;44;32;0world!!1

    Что же ты, ____ [компилятор], делаешь…

    Загружаем хорошо обрезанный кусок кода на CppInsight, компилируем и смотрим выхлоп. Делаем выводы:

    • судя по строке #43 вкладки Insight, шаблон auto makeString(Numeric value) раскрылся для типа char

    • а судя по #68, шаблон auto makeString(const Iterable& iterable) раскрылся для литерала "Hello, " который имеет тип char[8].

    • к слову, компилятор заботливо инстанцировал для нас template<>
      inline constexpr const bool isString = false;
      так как мы не предоставили нужной специализации, чтоб объясняет использование Iterable-версии makeString.

    Мы уже знаем, про Non-type template parameters, , а потому, по мотивам вывода CppInsight добавим ещё одну специализацию. Итак:

    // makeString.hpp
    #pragma once
    #include 
    #include 
    
    namespace Impl
    {
    template  inline constexpr bool isString  = false;
    template <> inline constexpr bool isString       = true;
    template <> inline constexpr bool isString             = true;
    template <> inline constexpr bool isString       = true;
    template <> inline constexpr bool isString = true;
    template <> inline constexpr bool isString  = true;
    
    template  inline constexpr bool isString = true;
    }
    
    template 
    auto makeString(const Object &object) -> decltype(object.to_string())
    {
        return object.to_string();
    }
    
    template 
    auto makeString(Numeric value) -> decltype(std::to_string(value))
    {
        return std::to_string(value);
    }
    
    template 
    auto makeString(const Iterable& iterable) 
        -> std::enable_if_t, 
                            decltype(makeString(*std::begin(iterable)))>
    {
        std::string result;
        for (const auto &i : iterable)
        {
            if (!result.empty())
                result += ';';
            result += makeString(i);
        }
    
        return result;
    }
    
    template 
    auto makeString(const String& s)
        -> std::enable_if_t, std::string>
    {
        return std::string(s);
    }
    

    Компилируем, запускаем, теперь работает именно так, как ожидалось:

    a: A; b: B{1}; pi: 3.140000
    xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
    Hello, world!!1

    Библиотека type_traits

    Код работает, запускается, но вместе с некоторым пониманием, как оно работает может возникнуть желание глянуть, что же еще интерсного есть на cppreference в стандартной библиотеке для type_traits. А есть там, в частности, trait std::is_convertible, что наталкивает на идею избавиться от собственных велосипедов и их поддержки. Положим, что строка — это то, что неявно конвертируется в std::string. А когда окажется, что std::string_view не конвертируется в std::string неявно, то добавим отдельную специализацию для string_view.

    namespace Impl
    {
        template  
        inline constexpr bool isString = std::is_convertible_v;
    
        template <>   
        inline constexpr bool isString = true;  // 'cause is not implicitly convertible to std::string
    }

    Компилируем, запускаем, и если сил радоваться больше не осталось, то не радуемся. Но если остались, то обращаем внимание на то, что шаблон auto makeString(const String& s) принимает аргумент по константной ссылке и создает копию. Для вызова makeString(std::string("world")); аргументом которого является временный объект, копию можно было бы не создавать, а использовать перемещение.

    Универсальные ссылки и std: forward

    Исторически сложилось (несколько часов тому назад), что параметром нашего makeString была шаблонная lvalue-ссылка. Но раз у нас параметр шаблонный, то мы можем вспомнить про идеальную передачу и универсальные ссылки в С++: если шаблонный параметр выглядит как T&&, то при инстанцировании он может принимать как lvalue-ссылку (например, std::string& или const string&), так и rvalue-ссылку на временный объект std::string&&.

    При этом нужно помнить, что внутри инстанцируемой функции rvalue всегда превращается в один из видов lvalue. Подробнее можно ознакомиться в статье по ссылке выше, но на данный момент достаточно понимания того, что T&& в объявлении шаблонного метода превратится в наиболее подходящую ссылку, которую внутри такого метода можно или передать тем же способом используя std::forward, или преобразовать в rvalue с использованием std::move, или использовать как lvalue.

    Поиграть со ссылками и идеальной передачей можно на CppInsight, пока же вспомним, что std: forward нам идеально подойдет для того, чтобы перенаправить универсальную ссылку дальше в конструктор std::string:

    Кусок документации

    template
    void wrapper(T&& arg) 
    {
        // arg is always lvalue
        foo(std::forward(arg)); // Forward as lvalue or as rvalue, depending on T
    }
    • If a call to wrapper() passes an rvalue std::string, then T is deduced to std::string (not std::string&, const std::string&, or std::string&&), and std::forward ensures that an rvalue reference is passed to foo.

    • If a call to wrapper() passes a const lvalue std::string, then T is deduced to const std::string&, and std::forward ensures that a const lvalue reference is passed to foo.

    • If a call to wrapper() passes a non-const lvalue std::string, then T is deduced to std::string&, and std::forward ensures that a non-const lvalue reference is passed to foo.

    В результате чтения тонны документации и нескольких статей, приходим к идеальной передаче параметра в нашей специализации для строк:

    template 
    auto makeString(String&& s)
        -> std::enable_if_t, std::string>
    {
        return std::string(std::forward(s));
    }

    Как обычно, компилируем, запускаем, но радуемся только после того, как с помощью функции Step Into отладчика зайдем в перемещающий конструктор std::string(std::string&&) во время вызова makeString(std::string("!!1")) и убедимся, что копирования не происходит. У меня не произошло — я доволен.

    // main.cpp
    #include 
    #include 
    #include 
    
    #include "makeString.hpp"
    
    struct A 
    {
        std::string to_string() const { return "A"; }
    };
    
    struct B
    {
        int m_i = 0;
        std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
    };
    
    int main()
    {
        A a;
        B b = {1};
    
        const std::vector xs = {1, 2, 3};
        const std::set ys = {4, 5, 6};
        const double zs[] = {7, 8, 9};
    
        std::cout << "a: " << makeString(a) << "; b: " << makeString(b) 
                  << "; pi: " << makeString(3.14) << std::endl
                  << "xs: " << makeString(xs) << "; ys: " << makeString(ys) 
                  << "; zs: " << makeString(zs)
                  << std::endl;
    
        std::cout << makeString("Hello, ") 
                  << makeString(std::string_view("world")) 
                  << makeString(std::string("!!1")) 
                  << std::endl;
    
        const std::string constHello = "const hello!";
        std::cout << makeString(constHello)
                  << std::endl;
    }
    // makeString.hpp
    #pragma once
    #include 
    #include 
    
    namespace Impl
    {
        template  
        inline constexpr bool isString = std::is_convertible_v;
    
        template <>   
        inline constexpr bool isString = true;  // 'cause is not implicitly convertible to std::string
    }
    
    template 
    auto makeString(const Object &object) -> decltype(object.to_string())
    {
        return object.to_string();
    }
    
    template 
    auto makeString(Numeric value) -> decltype(std::to_string(value))
    {
        return std::to_string(value);
    }
    
    template 
    auto makeString(const Iterable &iterable) 
        -> std::enable_if_t, 
                             decltype(makeString(*std::begin(iterable)))>
    {
        std::string result;
        for (const auto &i : iterable)
        {
            if (!result.empty())
                result += ';';
            result += makeString(i);
        }
    
        return result;
    }
    
    template 
    auto makeString(String&& s)
        -> std::enable_if_t, std::string>
    {
        return std::string(std::forward(s));
    }

    Variadic templates — шаблоны с переменным числом параметров

    Менее сознательный автор на данном этапе спросил бы читателя, не написать ли ему теперь про простое практическое применение шаблонов с переменным числом параметров, но для меня очевидно, что заголовочный файл на 48 строк, из которых половина — отступы, и один тестовый метод main() размером в 20 строк кода, на добротную статью «не тянут».

    Итак, как насчет вызова makeString("xs: ", xs, "; and float is: ", 3.14f);? Подобные инициативы грозят очередной бессонной ночью с компилятором, а жизнь — коротка, следовательно бессонных ночей с компилятором в ней мало, а потому не следуют отказывать себе в этом удовольствии.

    Расширим задачу еще раз:

    std::cout << makeString("xs: ", xs, "; and float is: ", 3.14f) 
              << std::endl;

    И придумаем один из путей решения: makeString с несколькими параметрами — это как makeString с одним параметром, но несколько раз. Другими словами, makeString(a, b, c) эквивалентно makeString(a) + makeString(b) + makeString(c);

    Рекурсивный подход к variadic templates

    Первая из пришедших в голову идей звучит так: makeString(first, rest...) => makeString(first) + makeString(rest...); до тех пор, пока rest не пустой. А когда rest пустой, рекурсию можно остановить возвратом пустой строки.

    std::string makeString()
    {
        return std::string();
    }
    
    template 
    std::string makeString(First &&first, Rest &&...rest)
    {
        return makeString(std::forward(first)) 
             + makeString(std::forward(rest)...);
    }

    Собрали, запустили, упали. К счасть, не под стол, а по исключению segmentation fault. Суровые линуксоиды снова могут воспользоваться CppInsight, а счастливые пользователи VS 2019 смотрят на предупреждения компилятора и уже видят, что:

    warning C4717: 'makeString': recursive on all control paths, function will cause runtime stack oveflow

    Действительно, вызов makeString(std::forward(first)) для приводит к вызову std::string makeString(First&& first, Rest&& ...rest) с пустым parameter-pack Rest, в котором мы снова вызываем makeString(First&& first, Rest&& ...rest)с пустым Rest. Таким образом, мы получаем бесконечную рекурсию и переполнение стека.

    Но если makeString с переменным числом параметров будет состоять из двух фиксированных параметров и остатка переменной длины, то рекурсию можно остановить на makeString с одним параметром, которых у нас уже написана целая пачка. Проверяем:

    template 
    std::string makeString(First&& first, Second&& second, Rest&&... rest)
    {
    	return makeString(std::forward(first)) 
           + makeString(std::forward(second), std::forward(rest)...);
    }

    Окинем взглядом весь наш код перед компиляцией исправленного варианта и запуском:

    // makeString.hpp
    #pragma once
    #include 
    #include 
    
    namespace Impl
    {
        template  
        inline constexpr bool isString = std::is_convertible_v;
    
        template <>   
        inline constexpr bool isString = true;  // 'cause is not implicitly convertible to std::string
    }
    
    template 
    auto makeString(const Object &object) -> decltype(object.to_string())
    {
        return object.to_string();
    }
    
    template 
    auto makeString(Numeric value) -> decltype(std::to_string(value))
    {
        return std::to_string(value);
    }
    
    template 
    auto makeString(const Iterable &iterable) 
        -> std::enable_if_t, 
                            decltype(makeString(*std::begin(iterable)))>
    {
        std::string result;
        for (const auto &i : iterable)
        {
            if (!result.empty())
                result += ';';
            result += makeString(i);
        }
    
        return result;
    }
    
    template 
    auto makeString(String&& s)
        -> std::enable_if_t, std::string>
    {
        return std::string(std::forward(s));
    }
    
    template 
    std::string makeString(First&& first, Second&& second, Rest&&... rest)
    {
    	  return makeString(std::forward(first)) 
             + makeString(std::forward(second), std::forward(rest)...);
    }

    Запускаем, радуемся выводу:

    a: A; b: B{1}; pi: 3.140000
    xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
    Hello, world!!1
    const hello!
    xs: 1;2;3; and float is: 3.140000

    Подход со сверткой (fold expression) к Variadic templates

    Уже неплохо, но можно лучше: мы можем избежать рекурсии там, где можно свернуть «пачку параметров» c использованием одной и тоже же операции. К счастью, мы наверняка читали в каком-то сравочнике, что начиная с 17 го стандарта в C++ есть fold expression, который позволяет свернуть пачку параметров переменной длины в одну большую операцию без рекурсии.

    Имея унарную операцию result += x[n], где x[n] — это очередной makeString(pack[n]), не забывая про возможность рекурсии и горький опыт переполнения стека, выполним свертку для parameter pack с размером больше 1, т.к. parameter pack размером 1 уже обрабатывается имеющимися шаблонами с одним параметром.

    template 
    auto makeString(Pack&&... pack) 
    	-> std::enable_if_t<(sizeof...(Pack) > 1), std::string>
    {
        return (... += makeString(std::forward(pack)));
    }

    Как обычно, окинем наше произведение взглядом перед запуском:

    // main.cpp
    
    #include 
    #include 
    #include 
    
    #include "makeString.hpp"
    
    struct A 
    {
        std::string to_string() const { return "A"; }
    };
    
    struct B
    {
        int m_i = 0;
        std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
    };
    
    int main()
    {
        A a;
        B b = {1};
    
        const std::vector xs = {1, 2, 3};
        const std::set ys = {4, 5, 6};
        const double zs[] = {7, 8, 9};
    
        std::cout << "a: " << makeString(a) << "; b: " << makeString(b) 
                  << "; pi: " << makeString(3.14) << std::endl
                  << "xs: " << makeString(xs) << "; ys: " << makeString(ys) 
                  << "; zs: " << makeString(zs)
                  << std::endl;
    
        std::cout << makeString("Hello, ") 
                  << makeString(std::string_view("world")) 
                  << makeString(std::string("!!1")) 
                  << std::endl;
    
        const std::string constHello = "const hello!";
        std::cout << makeString(constHello)
                  << std::endl;
    
        std::cout << makeString("xs: ", xs, "; and float is: ", 3.14f) 
                  << std::endl;
    }
    // makeString.hpp
    #pragma once
    #include 
    #include 
    
    namespace Impl
    {
        template  
        inline constexpr bool isString = std::is_convertible_v;
    
        template <>   
        inline constexpr bool isString = true;  // 'cause is not implicitly convertible to std::string
    }
    
    template 
    auto makeString(const Object &object) -> decltype(object.to_string())
    {
        return object.to_string();
    }
    
    template 
    auto makeString(Numeric value) -> decltype(std::to_string(value))
    {
        return std::to_string(value);
    }
    
    template 
    auto makeString(const Iterable &iterable) 
        -> std::enable_if_t, 
                            decltype(makeString(*std::begin(iterable)))>
    {
        std::string result;
        for (const auto &i : iterable)
        {
            if (!result.empty())
                result += ';';
            result += makeString(i);
        }
    
        return result;
    }
    
    template 
    auto makeString(String&& s)
        -> std::enable_if_t, std::string>
    {
        return std::string(std::forward(s));
    }
    
    template 
    auto makeString(Pack&&... pack) -> std::enable_if_t<(sizeof...(Pack) > 1), std::string>
    {
        return (... += makeString(std::forward(pack)));
    }

    Проверяем:

    a: A; b: B{1}; pi: 3.140000
    xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
    Hello, world!!1
    const hello!
    xs: 1;2;3; and float is: 3.140000

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

    Концепты и ограничения (constraints) — синтаксический сахар для SFINAE

    Если бы я обновил Visual Studio 2019 до версии 16.3, или gсс и libstdcpp до 10-й, я бы смог использовать концепты для SFINAE.

    Концепты — это требования к типу шаблонного параметра, которые компилятор проверяет на этапе подстановки аргументов. По сути, это очень похоже на std: enable_if за исключением того, что нам больше не нужно вручную провоцировать ошибки подстановки.

    Предлагаю ознакомиться с концептами, как возможностью языка С++ поближе:

    template  
    concept IsString = std::is_convertible_v 
                    || std::is_same_v;
    
    template 
    std::string makeString(String&& s)
    {
        return std::string(std::forward(s));
    }

    Итак, здесь с помощью средств языка задан концепт IsString, которому удовлетворяют те типы, для которых выполняется булево условие is_convertible_v
    || is_same_v
    , где is_convertible_v и is_same_v — это обычные типы из библиотеки type_traits. Далее у нас есть makeString, шаблонный параметр которого не какой-нибудь любой typename, а только тот тип, который удовлетворяет условию IsString.

    Кроме того, концепты могут накладывать требование на типы. Пусть концепт HasStdConversion будет проверять, что код T a; std::to_string(a); успешно скомпилируется:

    template 
    concept HasStdConversion = requires (T number) { std::to_string(number); };

    По-моему, стало интереснее. Но мы можем пойти дальше и наложить с помощью концепта требование к типу результата вызова функции для объекта, да простят меня лингвисты за 4 существительных подряд. Для применения требования к типу безо всяких decltype() удобно использовать другие концепты, в том числе, из стандартной библиотеки концептов.

    Пусть HasToString будет концептом, который проверяет возможность вызова object.to_string() для своего аргумента и требует, чтобы результат этого вызова удовлетворял концепту std::is_convertible:

    #include  // standard library concepts
    template  
    concept HasToString = requires (const T& object) 
    { 
        { object.to_string() } -> std::convertible_to; 
    };
    
    template 
    std::string makeString(const Object& object)
    {
        return object.to_string();
    }

    Заметим, что первый аргумент шаблона концепта всегда подставляется неявно. Это особенности реализации концептов в ядре С++, это просто нужно знать, но благодаря этом их использование выглядит настолько лаконичным, как в наших объявлениях makeString.

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

    Пусть у нас будут концепты IsContainer, по которому можно итерироваться, и IsString, которому удовлетворяют только строки. Тогда в функции makeString для контейнеров мы можем наложить сразу два требования на её параметр:

    template 
    concept IsContainer = requires (const T& container) { std::begin(container); };
    
    template  
    concept IsString = std::is_convertible_v 
                    || std::is_same_v;
    
    template 
    requires (IsContainer && !IsString)
    std::string makeString(const Container& iterable)
    {
        std::string result;
        for (const auto &i : iterable)
        {
            if (!result.empty())
                result += ';';
            result += makeString(i);
        }
    
        return result;
    }

    В этом шаблоне makeString, typename Container — это тип, на который наложены ограничения IsConainer и not IsString.

    Остался шаблон с переменным числом параметров, и по-моему, требование для него получается тривиальным:

    template 
    requires (sizeof...(Pack) > 1)
    std::string makeString(Pack&&... pack)
    {
        return (... += makeString(std::forward(pack)));
    }

    Стало ли с концептами лучше?

    Для ответа на этот вопрос добавим немного не компилируемого кода:

    #include 
    //...
    int main()
    {
    // ...
        std::map keys = { {1,2}, { 3,4} };
        makeString(keys);
    }

    И оценим вывод компилятора:

    [build] In file included from ../main.cpp:6:
    [build] ../makeString.hpp: In instantiation of ‘std::string makeString(const Container&) [with Container = std::map; std::string = std::__cxx11::basic_string]’:
    [build] ../main.cpp:26:20:   required from here
    [build] ../makeString.hpp:49:29: error: no matching function for call to ‘makeString(const std::pair&)’
    [build]    49 |         result += makeString(i);
    [build]       |                   ~~~~~~~~~~^~~
    [build] ../makeString.hpp:29:13: note: candidate: ‘std::string makeString(const Object&) [with Object = std::pair; std::string = std::__cxx11::basic_string]’
    [build]    29 | std::string makeString(const Object& object)
    [build]       |             ^~~~~~~~~~
    [build] ../makeString.hpp:29:13: note: constraints not satisfied
    [build] ../makeString.hpp:35:13: note: candidate: ‘std::string makeString(Numeric) [with Numeric = std::pair; std::string = std::__cxx11::basic_string]’
    [build]    35 | std::string makeString(Numeric value)
    [build]       |             ^~~~~~~~~~
    [build] ../makeString.hpp:35:13: note: constraints not satisfied
    [build] ../makeString.hpp:42:13: note: candidate: ‘std::string makeString(const Container&) [with Container = std::pair; std::string = std::__cxx11::basic_string]’
    [build]    42 | std::string makeString(const Container& iterable)
    [build]       |             ^~~~~~~~~~
    [build] ../makeString.hpp:42:13: note: constraints not satisfied
    [build] ninja: build stopped: subcommand failed.
    [build] Build finished with exit code 1

    В переводе на русский язык, во время инстанцирования makeString для контейнеров в строке 26 моего испорченного кода, не нашлось makeString для pair, т.к. ни один из кандидатов не подошёл по требованиям.

    Для сравнения, заменю makeString.hpp на версию без Concepts

    build] ../main.cpp: In function ‘int main()’:
    [build] ../main.cpp:26:20: error: no matching function for call to ‘makeString(std::map&)’
    [build]    26 |     makeString(keys);
    [build]       |                    ^
    [build] In file included from ../main.cpp:6:
    [build] ../makeString.hpp:16:6: note: candidate: ‘template decltype (object.to_string()) makeString(const Object&)’
    [build]    16 | auto makeString(const Object &object) -> decltype(object.to_string())
    [build]       |      ^~~~~~~~~~
    [build] ../makeString.hpp:16:6: note:   template argument deduction/substitution failed:
    [build] ../makeString.hpp: In substitution of ‘template decltype (object.to_string()) makeString(const Object&) [with Object = std::map]’:
    [build] ../main.cpp:26:20:   required from here
    [build] ../makeString.hpp:16:58: error: ‘const class std::map’ has no member named ‘to_string’
    [build]    16 | auto makeString(const Object &object) -> decltype(object.to_string())
    [build]       |                                                   ~~~~~~~^~~~~~~~~
    [build] ../makeString.hpp:22:6: note: candidate: ‘template decltype (std::__cxx11::to_string(value)) makeString(Numeric)’
    [build]    22 | auto makeString(Numeric value) -> decltype(std::to_string(value))
    [build]       |      ^~~~~~~~~~
    [build] ../makeString.hpp:22:6: note:   template argument deduction/substitution failed:
    [build] ../makeString.hpp: In subs
        
                
                        

    © Habrahabr.ru