О шаблонах в С++, чуть сложнее
После недавней статьи о шаблонах С++ для начинающих осталось жгучее желание показать что-нибудь похожее, но на практическом примере, да так, чтобы и порог входа был не высоким, и чтобы скучно не было. А так как в голове крутится задача перевода чего бы то ни было в строку, то этим и предлагаю заняться всем, кто хочет потрогать компилятор за шаблоны.
Оглавление
Проблема и предлагаемое решение
Постановка задачи
Простой шаблон
Специализация шаблона функции и перегрузка функций
SFINAE (Substitution Failure Is Not An Error) — если подстановка не сработала, то её можно проигронировать
SFINAE и trailing return type
Пишем makeString () для коллекций
Попытка написать makeString () для строк
Type traits — свойства типов и специализация шаблонов по числовому параметру (by Non-type template parameter)
Что же ты, ____ [компилятор], делаешь…
Библиотека type_traits
Универсальные ссылки и std: forward
Variadic templates — шаблоны с переменным числом параметров
Рекурсивный подход к variadic templates
Подход со сверткой (fold expression) к Variadic templates
Концепты и ограничения (constraints) — синтаксический сахар для SFINAE
Стало ли с концептами лучше?
Итоги
Проблема
В имеющемся 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);
}
Постараюсь расшифровать приведенные выше руны:
Первая функция имеет два шаблонных параметра: тип объекта, и безымянный неиспользуемый параметр того же типа, который вернет вызов
Object::to_string()
для сферического экземпляраObject
в вакууме.std::declval
в данном случае — это замена конструктора по умолчанию, т.к. у типа Object такого конструктора может не быть.Для типов
A
иB
данная конструкция успешно распарсится компилятором в момент вызоваmakeString
, но если первый аргумент типаdouble
, компилятор не сможет вывести типstd::declval
и проигнорирует это определение.().to_string() Вторая функция принимает два параметра, второй из них — это указатель на тот же тип, который вернет вызов
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
// (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(...)
не даст ему этого сделать если:
Не удается вычислить тип, который вернет
object.to_string()
;Не удается вычислить тип, который вернет
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
.
Вспомнив, что шаблонным параметром может быть не только произвольный тип, но и значение фиксированного типа, например 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
возможно, в то время, как enable_if
не определен, и вызовет ошибку подстановки (что, как нам известно, «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 rvaluestd::string
, thenT
is deduced tostd::string
(notstd::string&
,const std::string&
, orstd::string&&
), andstd::forward
ensures that an rvalue reference is passed tofoo
.If a call to
wrapper()
passes a const lvaluestd::string
, thenT
is deduced toconst std::string&
, andstd::forward
ensures that a const lvalue reference is passed tofoo
.If a call to
wrapper()
passes a non-const lvaluestd::string
, thenT
is deduced tostd::string&
, andstd::forward
ensures that a non-const lvalue reference is passed tofoo
.
В результате чтения тонны документации и нескольких статей, приходим к идеальной передаче параметра в нашей специализации для строк:
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_vis_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
И оценим вывод компилятора:
[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