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

Оглавление

  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. Отличие концептов от классического SFINAE и Partial ordering of constraints

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

  19. Итоги

Проблема

В имеющемся 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 <iostream>
#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 <string>

template <typename Object>
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 <string>

template <typename Object>
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 <string>

template <typename Object>
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 <typename Numeric>
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 <string>
#include <utility>  // for std::declval

// (1)
template <typename Object, 
          typename = decltype(std::declval<Object>().to_string())>
std::string makeString(const Object& object)
{
    return object.to_string();
}

namespace Impl { bool acceptNumber(int); }

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

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

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

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

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

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

SFINAE и Trailing return type

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

// makeString.hpp
#pragma once
#include <string>

// (3)
template <typename Object>
auto makeString(const Object &object) -> decltype(object.to_string())
{
    return object.to_string();
}

// (4)
template <typename Numeric>
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<int> xs = {1, 2, 3};
    const std::set<float> 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<int>&)’, т.к. оба имеющихся шаблона не прошли подстановку, а значит нужно написать третий.

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

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

template <typename Iterable>
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<int> xs = {1, 2, 3};
    const std::set<float> 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, подходят под параметр шаблона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 <typename NotString> inline constexpr bool isString  = false;
template <> inline constexpr bool isString<std::string>       = true;
template <> inline constexpr bool isString<char*>             = true;
template <> inline constexpr bool isString<const char*>       = true;
template <> inline constexpr bool isString<const char* const> = true;
template <> inline constexpr bool isString<std::string_view>  = true;
}

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

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

template <bool B, class T = void> struct enable_if;
template <class T> struct enable_if<true, T> { using type = T; };
// syntax sugar: 'enable_if_v' is equivalent of 'typename enable_if<B,T>::type'
template <bool B, class T> using enable_if_t = typename enable_if<B,T>::type;

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

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

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

// makeString.hpp
#pragma once
#include <string>
#include <type_traits>

namespace Impl
{
template <typename NotString> inline constexpr bool isString  = false;
template <> inline constexpr bool isString<std::string>       = true;
template <> inline constexpr bool isString<char*>             = true;
template <> inline constexpr bool isString<const char*>       = true;
template <> inline constexpr bool isString<const char* const> = true;
template <> inline constexpr bool isString<std::string_view>  = true;
}

template <typename Object>
auto makeString(const Object& object) -> decltype(object.to_string())
{
    return object.to_string();
}

template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
    return std::to_string(value);
}

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

    return result;
}

template <typename String>
auto makeString(const String& s)
    -> std::enable_if_t<Impl::isString<String>, 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<char [8]> = false;
    так как мы не предоставили нужной специализации. Это объясняет использование Iterable-версии makeString.

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

// makeString.hpp
#pragma once
#include <string>
#include <type_traits>

namespace Impl
{
template <typename NotString> inline constexpr bool isString  = false;
template <> inline constexpr bool isString<std::string>       = true;
template <> inline constexpr bool isString<char*>             = true;
template <> inline constexpr bool isString<const char*>       = true;
template <> inline constexpr bool isString<const char* const> = true;
template <> inline constexpr bool isString<std::string_view>  = true;

template <std::size_t N> inline constexpr bool isString<char[N]> = true;
}

template <typename Object>
auto makeString(const Object &object) -> decltype(object.to_string())
{
    return object.to_string();
}

template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
    return std::to_string(value);
}

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

    return result;
}

template <typename String>
auto makeString(const String& s)
    -> std::enable_if_t<Impl::isString<String>, 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 <typename MaybeString> 
    inline constexpr bool isString = std::is_convertible_v<MaybeString, std::string>;

    template <>   
    inline constexpr bool isString<std::string_view> = 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<class T>
void wrapper(T&& arg) 
{
    // arg is always lvalue
    foo(std::forward<T>(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 <typename String>
auto makeString(String&& s)
    -> std::enable_if_t<Impl::isString<String>, std::string>
{
    return std::string(std::forward<String>(s));
}

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

// main.cpp
#include <iostream>
#include <vector>
#include <set>

#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<int> xs = {1, 2, 3};
    const std::set<float> 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 <string>
#include <type_traits>

namespace Impl
{
    template <typename MaybeString> 
    inline constexpr bool isString = std::is_convertible_v<MaybeString, std::string>;

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

template <typename Object>
auto makeString(const Object &object) -> decltype(object.to_string())
{
    return object.to_string();
}

template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
    return std::to_string(value);
}

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

    return result;
}

template <typename String>
auto makeString(String&& s)
    -> std::enable_if_t<Impl::isString<String>, std::string>
{
    return std::string(std::forward<String>(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 <typename First, typename... Rest>
std::string makeString(First &&first, Rest &&...rest)
{
    return makeString(std::forward<First>(first)) 
         + makeString(std::forward<Rest>(rest)...);
}

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

warning C4717: 'makeString<A &>': 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 <typename First, typename Second, typename... Rest>
std::string makeString(First&& first, Second&& second, Rest&&... rest)
{
	return makeString(std::forward<First>(first)) 
       + makeString(std::forward<Second>(second), std::forward<Rest>(rest)...);
}

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

// makeString.hpp
#pragma once
#include <string>
#include <type_traits>

namespace Impl
{
    template <typename MaybeString> 
    inline constexpr bool isString = std::is_convertible_v<MaybeString, std::string>;

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

template <typename Object>
auto makeString(const Object &object) -> decltype(object.to_string())
{
    return object.to_string();
}

template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
    return std::to_string(value);
}

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

    return result;
}

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

template <typename First, typename Second, typename... Rest>
std::string makeString(First&& first, Second&& second, Rest&&... rest)
{
	  return makeString(std::forward<First>(first)) 
         + makeString(std::forward<Second>(second), std::forward<Rest>(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 <typename... Pack>
auto makeString(Pack&&... pack) 
	-> std::enable_if_t<(sizeof...(Pack) > 1), std::string>
{
    return (... += makeString(std::forward<Pack>(pack)));
}

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

// main.cpp

#include <iostream>
#include <vector>
#include <set>

#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<int> xs = {1, 2, 3};
    const std::set<float> 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 <string>
#include <type_traits>

namespace Impl
{
    template <typename MaybeString> 
    inline constexpr bool isString = std::is_convertible_v<MaybeString, std::string>;

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

template <typename Object>
auto makeString(const Object &object) -> decltype(object.to_string())
{
    return object.to_string();
}

template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
    return std::to_string(value);
}

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

    return result;
}

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

template <typename... Pack>
auto makeString(Pack&&... pack) -> std::enable_if_t<(sizeof...(Pack) > 1), std::string>
{
    return (... += makeString(std::forward<Pack>(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 <typename T> 
concept IsString = std::is_convertible_v<T, std::string> 
                || std::is_same_v<T, std::string_view>;

template <IsString String>
std::string makeString(String&& s)
{
    return std::string(std::forward<String>(s));
}

Итак, здесь с помощью средств языка задан концепт IsString, которому удовлетворяют те типы, для которых выполняется булево условие is_convertible_v<T, std::string>
|| is_same_v<T, std::string_view>
, где is_convertible_v и is_same_v – это обычные constexpr bool trait-ы из библиотеки type_traits. Далее у нас есть makeString, шаблонный параметр которого не какой-нибудь любой typename, а только тот тип, который удовлетворяет условию IsString.

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

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

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

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

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

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

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

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

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

template <typename T>
concept IsContainer = requires (const T& container) { std::begin(container); };

template <typename T> 
concept IsString = std::is_convertible_v<T, std::string> 
                || std::is_same_v<T, std::string_view>;

template <typename Container>
requires (IsContainer<Container> && !IsString<Container>)
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 <typename... Pack>
requires (sizeof...(Pack) > 1)
std::string makeString(Pack&&... pack)
{
    return (... += makeString(std::forward<Pack>(pack)));
}

Как обычно, компилируем, запускаем, и радуемся, что код работает, как задумано, потому что у нас нет контейнеров, которые самостоятельно переводят себя в строку, тем самым удовлетворяя одновременно и концепту IsContainer, и HasToString. Спасибо, @sergegers, за отличный тест-кейс.

Отличие концептов от классического SFINAE и Partial ordering of constraints

А что, если контейнер с собственным to_string всё-таки есть?

struct C
{
    std::string m_string;
  
    auto begin() const { return std::begin(m_string); }
    auto begin()       { return std::begin(m_string); }
    auto end() const   { return std::end(m_string); }
    auto end()         { return std::end(m_string); }

    std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};

// ...
std::cout << makeString( C { "a container with its own to_string()" } )
          << std::endl;

В таком виде, ни makeString со SFINAE, ни вариант с концептами не скомпилируется из-за неоднозначности, т.к. класс C одновременно удовлетворяет и требованию IsContainer и требованию HasToString, при этом оба концепта имеют одинаковый приоритет. Кратко о приоритете применения ограничений к шаблонам (Partial ordering of constraints) можно прочитать сами-знаете-где, а вот подробнее и понятнее рассказал Andrzej's в статье Ordering by constraints.

Если упрощённо, то применяется наиболее ограниченный constraint-ами вызов из всех подходящих. При этом степень ограниченности тем больше, чем больше requirement-ов или концептов применено. Подробнее о логике подсчета можно написать хорошую, добротную, статью (в чем я советую убедиться всем желающим по ссылке выше), но в двух словах: ограничение P более ограничено, чем ограничение Q только в том случае, если множество объектов Q включает в себя объекты P, но не наоборот. Во время этого анализа компилятор не может "заглядывать" внутрь выражений типа bool, поэтому классические constexpr trait-ы из C++17 могут не привести к ожидаемому результату.

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

namespace Impl
{
template <typename T>
concept HasToString = requires(const T& object)
{
    { object.to_string() } -> std::convertible_to<std::string>;
};

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

template <typename T>
concept IsContainer = !Impl::HasToString<T> && requires(const T& container)
{
    std::begin(container);
};

template <typename T>
concept IsString = IsContainer<T> && std::constructible_from<std::string, T>;
}

Итого, имеем:

  • HasToString - это класс без ограничений, у которого есть метод to_string(), возвращающий то, что неявно приводится к std::string.

  • HasStdConversion - это класс без ограничений, такой, что вызов std::to_string(number); для экземпляра этого класса number будет успешно скомпилирован

  • IsContainer - это !HasToString, для которого дополнительно вводится ограничение на компилируемость выражения std::begin(container)

  • IsString - это IsContainer для которого вводится ограничение "std::string можно явно сконструировать из T". Обратим внимание, что в данном случае std::constructible_from - это тоже концепт, что позволяет компилятору однозначно понять, что IsString более ограничивающий, чем IsContainer.

Теперь строковый литерал "hello" будет удовлетворять концепту IsString (т.е. sContainer && constructible_from<std::string, T>), std::vector<char> {'a', 'b'} будет удовлетворять концепту IsContainer, но не IsString, а обертка вокруг std::vector со своим собственным to_string перестанет быть IsContainer, потому что IsContainer требует !Impl::HasToString.

Обновим реализации makeString, не забывая про perfect forwarding:

// makeString.hpp
#pragma once

#include <concepts>
#include <iostream>
#include <set>
#include <string>
#include <type_traits>
#include <vector>

namespace Impl
{
template <typename T>
concept HasToString = requires(const T& object)
{
    { object.to_string() } -> std::convertible_to<std::string>;
};

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

template <typename T>
concept IsContainer = !Impl::HasToString<T> && requires(const T& container)
{
    std::begin(container);
};

template <typename T>
concept IsString = IsContainer<T> && std::constructible_from<std::string, T>;
} // namespace Impl

template <Impl::HasToString Object>
std::string makeString(Object&& object)
{
    return object.to_string();
}

template <Impl::HasStdConversion Numeric>
std::string makeString(Numeric&& value)
{
    return std::to_string(std::forward<Numeric>(value));
}

template <Impl::IsString String>
std::string makeString(String&& s)
{
    return std::string(std::forward<String>(s));
}

template <Impl::IsContainer Container>
std::string makeString(Container&& iterable)
{
    std::string result;
    for (auto&& i: std::forward<Container>(iterable))
    {
        if (!result.empty())
            result += ';';
        result += makeString(std::forward<decltype(i)>(i));
    }

    return result;
}

template <typename... Pack>
requires(sizeof...(Pack) > 1) 
std::string makeString(Pack&&... pack)
{
    return (... += makeString(std::forward<Pack>(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
C{"a conatiner with its own to_string()"}

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

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

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

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

[main] Building folder: vscode-test 
[build] Starting build
[proc] Executing command: /usr/bin/cmake --build /home/victor/vscode-test/build --config Debug --target all -j 4 --
[build] [1/2  50% :: 5.122] Building CXX object CMakeFiles/makeString.dir/main.cpp.o
[build] FAILED: CMakeFiles/makeString.dir/main.cpp.o 
[build] /usr/bin/x86_64-linux-gnu-g++-10    -g   -Wall -Wextra -pedantic -Werror -std=gnu++2a -MD -MT CMakeFiles/makeString.dir/main.cpp.o -MF CMakeFiles/makeString.dir/main.cpp.o.d -o CMakeFiles/makeString.dir/main.cpp.o -c ../main.cpp
[build] In file included from ../main.cpp:8:
[build] ../makeString.hpp: In instantiation of ‘std::string makeString(Container&&) [with Container = std::map<int, int>&; std::string = std::__cxx11::basic_string<char>]’:
[build] ../main.cpp:63:20:   required from here
[build] ../makeString.hpp:59:29: error: no matching function for call to ‘makeString(std::pair<const int, int>&)’
[build]    59 |         result += makeString(std::forward<decltype(i)>(i));
[build]       |                   ~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[build] ../makeString.hpp:34:13: note: candidate: ‘std::string makeString(Object&&) [with Object = std::pair<const int, int>&; std::string = std::__cxx11::basic_string<char>]’
[build]    34 | std::string makeString(Object&& object)
[build]       |             ^~~~~~~~~~
[build] ../makeString.hpp:34:13: note: constraints not satisfied
[build] ../makeString.hpp:40:13: note: candidate: ‘std::string makeString(Numeric&&) [with Numeric = std::pair<const int, int>&; std::string = std::__cxx11::basic_string<char>]’
[build]    40 | std::string makeString(Numeric&& value)
[build]       |             ^~~~~~~~~~
[build] ../makeString.hpp:40:13: note: constraints not satisfied
[build] ../makeString.hpp:46:13: note: candidate: ‘std::string makeString(String&&) [with String = std::pair<const int, int>&; std::string = std::__cxx11::basic_string<char>]’
[build]    46 | std::string makeString(String&& s)
[build]       |             ^~~~~~~~~~
[build] ../makeString.hpp:46:13: note: constraints not satisfied
[build] ../makeString.hpp:52:13: note: candidate: ‘std::string makeString(Container&&) [with Container = std::pair<const int, int>&; std::string = std::__cxx11::basic_string<char>]’
[build]    52 | std::string makeString(Container&& iterable)
[build]       |             ^~~~~~~~~~
[build] ../makeString.hpp:52:13: note: constraints not satisfied
[build] ninja: build stopped: subcommand failed.
[build] Build finished with exit code 1

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

Для сравнения, заменю 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<int, int>&)’
[build]    26 |     makeString(keys);
[build]       |                    ^
[build] In file included from ../main.cpp:6:
[build] ../makeString.hpp:16:6: note: candidate: ‘template<class Object> 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<class Object> decltype (object.to_string()) makeString(const Object&) [with Object = std::map<int, int>]’:
[build] ../main.cpp:26:20:   required from here
[build] ../makeString.hpp:16:58: error: ‘const class std::map<int, int>’ 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<class Numeric> 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 substitution of ‘template<class Numeric> decltype (std::__cxx11::to_string(value)) makeString(Numeric) [with Numeric = std::map<int, int>]’:
[build] ../main.cpp:26:20:   required from here
[build] ../makeString.hpp:22:58: error: no matching function for call to ‘to_string(std::map<int, int>&)’
[build]    22 | auto makeString(Numeric value) -> decltype(std::to_string(value))
[build]       |                                            ~~~~~~~~~~~~~~^~~~~~~
[build] In file included from /usr/include/c++/10/string:55,
[build]                  from /usr/include/c++/10/bits/locale_classes.h:40,
[build]                  from /usr/include/c++/10/bits/ios_base.h:41,
[build]                  from /usr/include/c++/10/ios:42,
[build]                  from /usr/include/c++/10/ostream:38,
[build]                  from /usr/include/c++/10/iostream:39,
[build]                  from ../main.cpp:1:
[build] /usr/include/c++/10/bits/basic_string.h:6597:3: note: candidate: ‘std::string std::__cxx11::to_string(int)’
[build]  6597 |   to_string(int __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6597:17: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘int’
[build]  6597 |   to_string(int __val)
[build]       |             ~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6608:3: note: candidate: ‘std::string std::__cxx11::to_string(unsigned int)’
[build]  6608 |   to_string(unsigned __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6608:22: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘unsigned int’
[build]  6608 |   to_string(unsigned __val)
[build]       |             ~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6616:3: note: candidate: ‘std::string std::__cxx11::to_string(long int)’
[build]  6616 |   to_string(long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6616:18: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘long int’
[build]  6616 |   to_string(long __val)
[build]       |             ~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6627:3: note: candidate: ‘std::string std::__cxx11::to_string(long unsigned int)’
[build]  6627 |   to_string(unsigned long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6627:27: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘long unsigned int’
[build]  6627 |   to_string(unsigned long __val)
[build]       |             ~~~~~~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6635:3: note: candidate: ‘std::string std::__cxx11::to_string(long long int)’
[build]  6635 |   to_string(long long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6635:23: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘long long int’
[build]  6635 |   to_string(long long __val)
[build]       |             ~~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6647:3: note: candidate: ‘std::string std::__cxx11::to_string(long long unsigned int)’
[build]  6647 |   to_string(unsigned long long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6647:32: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘long long unsigned int’
[build]  6647 |   to_string(unsigned long long __val)
[build]       |             ~~~~~~~~~~~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6658:3: note: candidate: ‘std::string std::__cxx11::to_string(float)’
[build]  6658 |   to_string(float __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6658:19: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘float’
[build]  6658 |   to_string(float __val)
[build]       |             ~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6667:3: note: candidate: ‘std::string std::__cxx11::to_string(double)’
[build]  6667 |   to_string(double __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6667:20: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘double’
[build]  6667 |   to_string(double __val)
[build]       |             ~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6676:3: note: candidate: ‘std::string std::__cxx11::to_string(long double)’
[build]  6676 |   to_string(long double __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6676:25: note:   no known conversion for argument 1 from ‘std::map<int, int>’ to ‘long double’
[build]  6676 |   to_string(long double __val)
[build]       |             ~~~~~~~~~~~~^~~~~
[build] In file included from ../main.cpp:6:
[build] ../makeString.hpp:28:6: note: candidate: ‘template<class Iterable> std::enable_if_t<(! isString<Iterable>), decltype (makeString((* std::begin(iterable))))> makeString(const Iterable&)’
[build]    28 | auto makeString(const Iterable &iterable)
[build]       |      ^~~~~~~~~~
[build] ../makeString.hpp:28:6: note:   template argument deduction/substitution failed:
[build] ../makeString.hpp: In substitution of ‘template<class Iterable> std::enable_if_t<(! isString<Iterable>), decltype (makeString((* std::begin(iterable))))> makeString(const Iterable&) [with Iterable = std::map<int, int>]’:
[build] ../main.cpp:26:20:   required from here
[build] ../makeString.hpp:30:44: error: no matching function for call to ‘makeString(const std::pair<const int, int>&)’
[build]    30 |                         decltype(makeString(*std::begin(iterable)))>
[build]       |                                  ~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~
[build] ../makeString.hpp:16:6: note: candidate: ‘template<class Object> 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<class Object> decltype (object.to_string()) makeString(const Object&) [with Object = std::pair<const int, int>]’:
[build] ../makeString.hpp:30:44:   required by substitution of ‘template<class Iterable> std::enable_if_t<(! isString<Iterable>), decltype (makeString((* std::begin(iterable))))> makeString(const Iterable&) [with Iterable = std::map<int, int>]’
[build] ../main.cpp:26:20:   required from here
[build] ../makeString.hpp:16:58: error: ‘const struct std::pair<const int, int>’ has no member named ‘to_string’
[build]    16 | auto makeString(const Object &object) -> decltype(object.to_string())
[build]       |                                                   ~~~~~~~^~~~~~~~~
[build] ../makeString.hpp: In substitution of ‘template<class Iterable> std::enable_if_t<(! isString<Iterable>), decltype (makeString((* std::begin(iterable))))> makeString(const Iterable&) [with Iterable = std::map<int, int>]’:
[build] ../main.cpp:26:20:   required from here
[build] ../makeString.hpp:22:6: note: candidate: ‘template<class Numeric> 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 substitution of ‘template<class Numeric> decltype (std::__cxx11::to_string(value)) makeString(Numeric) [with Numeric = std::pair<const int, int>]’:
[build] ../makeString.hpp:30:44:   required by substitution of ‘template<class Iterable> std::enable_if_t<(! isString<Iterable>), decltype (makeString((* std::begin(iterable))))> makeString(const Iterable&) [with Iterable = std::map<int, int>]’
[build] ../main.cpp:26:20:   required from here
[build] ../makeString.hpp:22:58: error: no matching function for call to ‘to_string(std::pair<const int, int>&)’
[build]    22 | auto makeString(Numeric value) -> decltype(std::to_string(value))
[build]       |                                            ~~~~~~~~~~~~~~^~~~~~~
[build] In file included from /usr/include/c++/10/string:55,
[build]                  from /usr/include/c++/10/bits/locale_classes.h:40,
[build]                  from /usr/include/c++/10/bits/ios_base.h:41,
[build]                  from /usr/include/c++/10/ios:42,
[build]                  from /usr/include/c++/10/ostream:38,
[build]                  from /usr/include/c++/10/iostream:39,
[build]                  from ../main.cpp:1:
[build] /usr/include/c++/10/bits/basic_string.h:6597:3: note: candidate: ‘std::string std::__cxx11::to_string(int)’
[build]  6597 |   to_string(int __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6597:17: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘int’
[build]  6597 |   to_string(int __val)
[build]       |             ~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6608:3: note: candidate: ‘std::string std::__cxx11::to_string(unsigned int)’
[build]  6608 |   to_string(unsigned __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6608:22: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘unsigned int’
[build]  6608 |   to_string(unsigned __val)
[build]       |             ~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6616:3: note: candidate: ‘std::string std::__cxx11::to_string(long int)’
[build]  6616 |   to_string(long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6616:18: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘long int’
[build]  6616 |   to_string(long __val)
[build]       |             ~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6627:3: note: candidate: ‘std::string std::__cxx11::to_string(long unsigned int)’
[build]  6627 |   to_string(unsigned long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6627:27: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘long unsigned int’
[build]  6627 |   to_string(unsigned long __val)
[build]       |             ~~~~~~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6635:3: note: candidate: ‘std::string std::__cxx11::to_string(long long int)’
[build]  6635 |   to_string(long long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6635:23: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘long long int’
[build]  6635 |   to_string(long long __val)
[build]       |             ~~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6647:3: note: candidate: ‘std::string std::__cxx11::to_string(long long unsigned int)’
[build]  6647 |   to_string(unsigned long long __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6647:32: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘long long unsigned int’
[build]  6647 |   to_string(unsigned long long __val)
[build]       |             ~~~~~~~~~~~~~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6658:3: note: candidate: ‘std::string std::__cxx11::to_string(float)’
[build]  6658 |   to_string(float __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6658:19: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘float’
[build]  6658 |   to_string(float __val)
[build]       |             ~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6667:3: note: candidate: ‘std::string std::__cxx11::to_string(double)’
[build]  6667 |   to_string(double __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6667:20: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘double’
[build]  6667 |   to_string(double __val)
[build]       |             ~~~~~~~^~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6676:3: note: candidate: ‘std::string std::__cxx11::to_string(long double)’
[build]  6676 |   to_string(long double __val)
[build]       |   ^~~~~~~~~
[build] /usr/include/c++/10/bits/basic_string.h:6676:25: note:   no known conversion for argument 1 from ‘std::pair<const int, int>’ to ‘long double’
[build]  6676 |   to_string(long double __val)
[build]       |             ~~~~~~~~~~~~^~~~~
[build] In file included from ../main.cpp:6:
[build] ../makeString.hpp:44:6: note: candidate: ‘template<class String> std::enable_if_t<isString<String>, std::__cxx11::basic_string<char> > makeString(String&&)’
[build]    44 | auto makeString(String&& s)
[build]       |      ^~~~~~~~~~
[build] ../makeString.hpp:44:6: note:   template argument deduction/substitution failed:
[build] In file included from /usr/include/c++/10/bits/move.h:57,
[build]                  from /usr/include/c++/10/bits/nested_exception.h:40,
[build]                  from /usr/include/c++/10/exception:148,
[build]                  from /usr/include/c++/10/ios:39,
[build]                  from /usr/include/c++/10/ostream:38,
[build]                  from /usr/include/c++/10/iostream:39,
[build]                  from ../main.cpp:1:
[build] /usr/include/c++/10/type_traits: In substitution of ‘template<bool _Cond, class _Tp> using enable_if_t = typename std::enable_if::type [with bool _Cond = false; _Tp = std::__cxx11::basic_string<char>]’:
[build] ../makeString.hpp:44:6:   required by substitution of ‘template<class String> std::enable_if_t<isString<String>, std::__cxx11::basic_string<char> > makeString(String&&) [with String = std::map<int, int>&]’
[build] ../main.cpp:26:20:   required from here
[build] /usr/include/c++/10/type_traits:2554:11: error: no type named ‘type’ in ‘struct std::enable_if<false, std::__cxx11::basic_string<char> >’
[build]  2554 |     using enable_if_t = typename enable_if<_Cond, _Tp>::type;
[build]       |           ^~~~~~~~~~~
[build] In file included from ../main.cpp:6:
[build] ../makeString.hpp:51:6: note: candidate: ‘template<class ... Pack> std::enable_if_t<(sizeof... (Pack) > 1), std::__cxx11::basic_string<char> > makeString(Pack&& ...)’
[build]    51 | auto makeString(Pack&&... pack) -> std::enable_if_t<(sizeof...(Pack) > 1), std::string>
[build]       |      ^~~~~~~~~~
[build] ../makeString.hpp:51:6: note:   template argument deduction/substitution failed:
[build] ninja: build stopped: subcommand failed.
[build] Build finished with exit code 1

Итоги

Я надеюсь, что в результате этого небольшого но интересного путешествия по справочникам С++, были приобретены и закреплены практические навыки написания полезного и относительно читаемого шаблонного кода. Кроме того, теперь у читателя есть список тем и ссылок для дальнейшего изучения, а у меня – обновлен gcc.

Предлагаю причесать код еще раз, посмотреть на него, и оценить результат:

// main.cpp

#include <iostream>
#include <vector>
#include <set>

#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) + "}"; }
};

struct C
{
    std::string m_string;
    auto begin() const { return std::begin(m_string); }
    auto begin()       { return std::begin(m_string); }
    auto end() const   { return std::end(m_string); }
    auto end()         { return std::end(m_string); }

    std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};

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

    const std::vector<int> xs = {1, 2, 3};
    const std::set<float> 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;

    std::cout << makeString(C { "a conatiner with its own to_string()" })
              << std::endl;
}
// makeString.hpp
#pragma once

#include <concepts>
#include <iostream>
#include <set>
#include <string>
#include <type_traits>
#include <vector>

namespace Impl
{
template <typename T>
concept HasToString = requires(const T& object)
{
    { object.to_string() } -> std::convertible_to<std::string>;
};

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

template <typename T>
concept IsContainer = !Impl::HasToString<T> && requires(const T& container)
{
    std::begin(container);
};

template <typename T>
concept IsString = IsContainer<T> && std::constructible_from<std::string, T>;

} // namespace Impl

template <Impl::HasToString Object>
std::string makeString(Object&& object)
{
    return object.to_string();
}

template <Impl::HasStdConversion Numeric>
std::string makeString(Numeric&& value)
{
    return std::to_string(std::forward<Numeric>(value));
}

template <Impl::IsString String>
std::string makeString(String&& s)
{
    return std::string(std::forward<String>(s));
}

template <Impl::IsContainer Container>
std::string makeString(Container&& iterable)
{
    std::string result;
    for (auto&& i: std::forward<Container>(iterable))
    {
        if (!result.empty())
            result += ';';
        result += makeString(std::forward<decltype(i)>(i));
    }

    return result;
}

template <typename... Pack>
requires(sizeof...(Pack) > 1) 
std::string makeString(Pack&&... pack)
{
    return (... += makeString(std::forward<Pack>(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
C{"a conatiner with its own to_string()"}

UPD: добавил краткое описание приоритетов ограничений и поддержку контейнеров с собственным to_string().

Комментарии (26)


  1. oleg-m1973
    13.01.2022 19:23

    template <Impl::HasStdConversion Numeric> std::string makeString(Numeric value)

    Code review: Для шаблонных параметров всегда используй ссылки, если специально не нужно другое


    1. 4eyes Автор
      13.01.2022 19:33
      +2

      В общем случае - согласен, в данном случае для обертки вокруг std::to_string ссылка не нужна, т. к. у нас гарантированно примитивный тип, который дёшево копируется.


      1. oleg-m1973
        13.01.2022 19:34

        Где ты это гарантируешь?


        1. 4eyes Автор
          13.01.2022 19:38
          +2

          Стандартом гарантированы 9 перегрузок std::to_string, принимающих примитивный тип по копии. Любой другой std::to_string приводит к undefined behavior: в моем случае это будет лишнее копирование.

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


          1. oleg-m1973
            13.01.2022 19:44

            Сегодня девять, завтра - десять. Лучше научиться следить за своим кодом. Ниже я показал примерную реализацию


    1. 0xd34df00d
      13.01.2022 21:19
      +8

      Плохой совет: это приводит к спиллу на стек тех аргументов, которые могли бы быть переданы через регистры. Например, если вы передадите std::pair<int, int> { 1, 2 } по значению, то получите что-то вроде


              movabs  rdi, 8589934593
              jmp     <otherfunc>

      а если по ссылке, то


              sub     rsp, 24
              mov     rax, QWORD PTR .LC0[rip]
              lea     rdi, [rsp+8]
              mov     QWORD PTR [rsp+8], rax
              call    <otherfunc>
              add     rsp, 24

      где .LC0 — метка констант 1 и 2.


      Или, на примере, смотрите, какую ерунду генерирует компилятор для bar1 здесь.


      В общем случае надо обмазываться проверками на тривиальность и на размер объекта.


      1. oleg-m1973
        13.01.2022 21:34

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


      1. KanuTaH
        13.01.2022 22:15
        -1

        А зачем вы искусственно запретили инлайнинг? :) Если убрать атрибут [[gnu::noinline]] у foo1(), то никакой разницы не будет, абсолютно. Вы же понимаете, что шаблонная функция с искусственно выключенной возможностью ее инлайнинга - это скорее исключение, чем правило?


        1. 0xd34df00d
          13.01.2022 22:50
          +5

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


          Если убрать атрибут [[gnu::noinline]] у foo1(), то никакой разницы не будет, абсолютно.

          Ну, то есть, во всех случаях для подобных типов рекомендация не делает лучше, а в ряде случаев (когда функция не инлайнится) — только хуже. Это делает рекомендацию хорошей или плохой?


    1. DSarovsky
      14.01.2022 10:26
      +2

      А в книге Джосаттиса рекомендовано наоборот передавать по значению, если специально не нужно другое, что он связывает с ограничениями низведения типов при передаче по ссылке. (Шаблоны C++. Справочник разработчика, глава 7.)


  1. oleg-m1973
    13.01.2022 19:32

    template <typename T> requires requires (T a) {{std::to_string(a)} -> std::convertible_to<std::string>;}
    std::string makeString(T &&value)
    {
    	return std::to_string(std::forward<T>(value));
    }


    1. 4eyes Автор
      13.01.2022 19:50

      Ага, perfect forwarding - неплохой вариант.


  1. kovserg
    13.01.2022 19:35

    А лямбду можно в строку преобразовать?


    1. 4eyes Автор
      13.01.2022 19:44

      Если ту, что буква греческого алфавита, то да!

      Но вообще, я изначально хотел запретить makeString(char) из-за его неочевидного поведения, но сначала не стал потому, что хотел вводить новые техники постепенно, потом - потому, что из этого вырос целый раздел про CppInsight раскапывание причин непонятного поведения компилятора. А потом забыл.


      1. oleg-m1973
        13.01.2022 20:04
        -3

        template <typename T> requires std::is_same_v<T, char>
        std::string makeString(T value)
        


        1. 4eyes Автор
          13.01.2022 20:36
          +1

          Для реализации makeString(Numeric) можно добавить if constexpr и вообще как шестнадцатеричные цифры это выводить, т.к. массив симвовов у нас строкой не считается.

          Или для makeString(Iterable) сделать вариант шаблона, который будет выводить такой массив в виде base64.

          В любом случае, это уже частные решения вывода массивов 1-байтных целых в строку. основной идеей было показать trait-ы на практическом примере и CppInsight.


          1. oleg-m1973
            13.01.2022 20:40
            -3

            Не увлекайся. Я просто показал, как сделать makeString конкретно для типа char


  1. horror_x
    13.01.2022 20:48

    Уже где-то отписывался по поводу этой КДПВ. Она высосана из пальца — к шаблонам не имеет отношения (то же самое было бы и с «foo» вместо длинного имени шаблонного метода), а ошибка диагностирована корректно и легко читается.

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


  1. sergegers
    14.01.2022 04:34
    +4

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

    Категорически не согласен. Вообще, если взглянуть на код в статье, то любого опытного человека начинают терзать смутные сомнения. Предположим, что мы написали библиотеку, в которй содержится функция makeString() и которую пользователь может расширять своими типами и даже семействами типов. А пользователь хочет добавить поддержку вот таких вот шаблонов:

    template <typename T>
    struct harry
    {
        std::vector<T>  m_vct;
    
        harry(std::initializer_list<T> vct) : m_vct{ vct } {}
    
        auto begin() { return m_vct.begin(); }
        auto begin() const { return m_vct.begin(); }
    
        auto end() { return m_vct.end(); }
        auto end() const { return m_vct.end(); }
    
        auto to_string() const { return "avada"; }
    };
    
    template <typename T>
    struct potter
    {
        std::vector<T>  m_vct;
    
        potter(std::initializer_list<T> vct) : m_vct{ vct } {}
    
        auto begin() { return m_vct.begin(); }
        auto begin() const { return m_vct.begin(); }
    
        auto end() { return m_vct.end(); }
        auto end() const { return m_vct.end(); }
    
        auto to_string() const { return "kedavra"; }
    };
    

    Как написать шаблон функции makeString(), чтобы для этих шаблонов классов использовалась функция класса to_string() как для объектов, а не итерирование, как для контейнеров. Напомню, что код в makeString.hpp - библиотечный, значит его править нельзя. Может быть так?

    template <typename Magic>
        requires (Impl::HasToString<Magic> && Impl::IsContainer<Magic>)
    std::string makeString(const Magic& magic)
    {
        return magic.to_string();
    }
    

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

    Numeric = HasStdConversion

    Object = HasToString

    Container = IsContainer

    String = IsContainer && IsString

    Тогда ограничение на Magic можно записать так

    Magic = IsContainer && HasToString

    Обращу внимание вот на что. При выборе функци кандидата для объекта типа harry<int> функции для Numeric и String не подходят, а вот Object и Container - подходят. Но Magic - более ограничивающий концепт, чем Object и Container, поэтому выбирается функция с ограничением Magic (см. Partial ordering of constraints). Переписываем ограничения на функции в требуемом виде.

    template <Impl::IsContainer Container>
    std::string makeString(Container &&iterable)
    {
        std::string result;
        for (auto &&i : std::forward<Container>(iterable))
        {
            if (!result.empty())
                result += ';';
            result += makeString(std::forward<decltype(i)>(i));
        }
    
        return result;
    }
    
    template <typename String>
        requires Impl::IsContainer<String> && Impl::IsString<String>
    std::string makeString(String &&s)
    {
        return std::string(std::forward<String>(s));
    }
    
    template <typename Magic>
        requires Impl::IsContainer<Magic> && Impl::HasToString<Magic>
    std::string makeString(Magic &&magic)
    {
        return std::forward<Magic>(magic).to_string();
    }
    

    и получаем

    
    #include <string>
    #include <type_traits>
    #include <concepts>
    #include <iostream>
    #include <set>
    #include <vector>
    
    namespace Impl
    {
    
    template <typename T>
    concept IsString = std::is_convertible_v<T, std::string>
        || std::is_same_v<T, std::string_view>;
    
    template <typename T>
    concept HasToString = requires (const T & object)
    {
        { object.to_string() } -> std::convertible_to<std::string>;
    };
    
    template <typename T>
    concept HasStdConversion = requires (T number) { std::to_string(number); };
    
    template <typename T>
    concept IsContainer = requires (const T & container) { std::begin(container); };
    
    }
    
    template <Impl::HasToString Object>
    std::string makeString(Object &&object)
    {
        return object.to_string();
    }
    
    template <Impl::HasStdConversion Numeric>
    std::string makeString(Numeric value)
    {
        return std::to_string(value);
    }
    
    template <Impl::IsContainer Container>
    std::string makeString(Container &&iterable)
    {
        std::string result;
        for (auto &&i : std::forward<Container>(iterable))
        {
            if (!result.empty())
                result += ';';
            result += makeString(std::forward<decltype(i)>(i));
        }
    
        return result;
    }
    
    template <typename String>
        requires Impl::IsContainer<String> && Impl::IsString<String>
    std::string makeString(String &&s)
    {
        return std::string(std::forward<String>(s));
    }
    
    template <typename... Pack>
        requires (sizeof...(Pack) > 1)
    std::string makeString(Pack&&... pack)
    {
        return (... += makeString(std::forward<Pack>(pack)));
    }
    
    
    ///////////////////////////////////////////////////////////////////////////////
    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) + "}"; }
    };
    
    template <typename T>
    struct harry
    {
        std::vector<T>  m_vct;
    
        harry(std::initializer_list<T> vct) : m_vct{ vct } {}
    
        auto begin() { return m_vct.begin(); }
        auto begin() const { return m_vct.begin(); }
    
        auto end() { return m_vct.end(); }
        auto end() const { return m_vct.end(); }
    
        auto to_string() const { return "avada"; }
    };
    
    template <typename T>
    struct potter
    {
        std::vector<T>  m_vct;
    
        potter(std::initializer_list<T> vct) : m_vct{ vct } {}
    
        auto begin() { return m_vct.begin(); }
        auto begin() const { return m_vct.begin(); }
    
        auto end() { return m_vct.end(); }
        auto end() const { return m_vct.end(); }
    
        auto to_string() const { return "kedavra"; }
    };
    
    template <typename Magic>
        requires Impl::IsContainer<Magic> && Impl::HasToString<Magic>
    std::string makeString(Magic &&magic)
    {
        return std::forward<Magic>(magic).to_string();
    }
    
    int main()
    {
        A a;
        B b = { 1 };
    
        const std::vector<int> xs = { 1, 2, 3 };
        const std::set<float> 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;
    
        std::cout << makeString(harry{ 99, -1, 4 })
            << std::endl;
        std::cout << makeString(potter{ 14, 88 })
            << std::endl;
    }

    и output

    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
    avada
    kedavra

    Кстати, при использовании partial ordering имеет смысл пользоваться одним типом ссылок в сигнатуре функции, чтобы не получать странные результаты.

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


    1. 4eyes Автор
      14.01.2022 21:59

      Спасибо, писал про концепты на остатках ночиного энтузиазма и не разобрался с приоритетом применения.

      Сегодня дописал код и статью для поддержки вашего примера.


    1. Kelbon
      15.01.2022 22:57
      -1

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


      1. 0xd34df00d
        15.01.2022 23:00

        Там очень тупой алгоритм доказательства. В частности, что N > 0 является более ограниченным, чем N >= 0, оно доказать уже не сможет (а самый тупой SMT-солвер, умеющий в любую арифметику, смог бы).


        1. Kelbon
          16.01.2022 07:20

          так оно работает на концептах, а не рандомных N > 0


      1. 4eyes Автор
        15.01.2022 23:39

        Конечно можно - вы справились! Моя задача была не в этом.


  1. NN1
    14.01.2022 11:09
    +2

    При работе с концептами лучше использовать std::same_as, чтобы не было неожиданостей.

    Для компилятора в случае концептов is_same_v<T, U> и is_same_v<U, T> будут разными атомами, поэтому лучше заранее избежать проблем.

    Тема раскрывается у Andrzej-я


  1. koloshmet
    14.01.2022 22:00
    +1

    Что значит std::to_string не расширяем новыми типами? Определяем to_string в неймспейсе нашего типа. Пишем ниеблоид в котором делаем

    using std::to_string;
    return to_string(std::forward<T>(t));

    И мы великолепны