Комментарии 35
Спасибо за такой подробный и развёрнутый комментарий! Действительно видна погружённость в тему. Про cppinsights знал, но как-то не доводилось много пользоваться при метапрограммировании. Про metashell вообще не знал. Чуть посмотрел. Сходу не понял как пользоваться - но, вероятно, штука крутая.
Что касается продвинутых техник настоящего метапрограммирования - тоже всё верно пишете, это важные темы, я планирую написать о них во второй статье. В данной же хотелось рассказть про шаблоны людям не имевшим с ними дела (и почти не имевшим дела с C++). Уровень целевой аудитории - люди, прошедшие курс C++ (не факт что академический курс, это могли быть онлайн-курсы) и программисты использующие другие языки, которым интересно было бы почитать про плюсы. Отсюда простые примеры, повторения в духе канала Discovery и отсутствие формализма. Я боялся отпугнуть читателей сложными терминами. При таком подходе есть риск ввести в некоторое заблуждение - но, с другой стороны, углубление знаний это часто путь от понимания с упрощениями и неточностями к более глубокому пониманию. Когда людям читают курс по школьной алгебре им говорят что нельзя брать квадратный корень из минус единицы, а потом в университете дают комплексные числа.
Ещё раз спасибо за такой подробный ответ. Я, скорее всего, не так глубоко понимаю тему как вы, поэтому, если это возможно, можно ли будет скинуть вам для бета-вычитки вторую статью с рассмотрением более сложных техник метапрограммирования (когда допишу её)? Думаю, она может стать глубже и полезнее для людей при такой экспертной оценке.
Камент интереснее статьи и тянет на отдельную статью.
Цель статьи была сделать так чтобы людям стало не так страшно пользоваться шаблонами, а не чтобы блеснуть знаниями. Метпрограммирования, сказать откровенно, боятся и избегают даже в индустрии. Многие коллеги по работе (а я работаю в не самой маленькой фирме) не используют шаблоны потому что они кажутся сложными. Как по мне, такое отношение к метапрограммированию вредит экосистеме C++ и как раз из надежды что-то поменять в этой ситуации была написана статья.
Возможно, я не до конца понял аудиторию хабра... Туторы по базовым темам тут в принципе не принято публиковать? Буду благодарен рекомендации русскоязычной площадки на которой публикация по основам будет более уместной.
P.S.: Коммент @fk01, развёрнутый в полноценную статью, я б тоже с удовольствием почитал.
У вас хорошая статья, спасибо за нее, хотя и базовая. Мне, как человеку, работавшему с С++ много лет назад (и я не использовал шаблоны), очень интересно и понятно.
Я так понимаю, вы планируете цикл статей
Спасибо большое, рад что статья могла быть полезной!) Да, я планирую ещё две, стараясь так же подробно разбирать материал. Одну по более продвинутым заложенным в язык механизмам связанным с шаблонами (их очень предметно описал@fk01, я думал попробовать более простым языком и с примерами рассказать о том же) и вторую по более высокоуровневым трюкам которые строятся поверх шаблонных языковых конструкций и лежат в основе метапрограммирования.
И он потерян...
Отладку ошибок в логике исполнения программы — нет.Ну как же нет, как минимум в типах и составных именах сложнее ориентироваться, плюс отладчику часто приходится мапать все инстанциированные варианты шаблона на файлы/строки, из-за чего он может подвисать.
Ну и про проблемы отдельных отладчиков сами же написали.
"template instantiation" - создание экземпляра?
Спасибо автору за статью и @fk01за отличные дополнения к ней! Я не пишу на языках семейства С (уже лет 20 только фортран! ;-), но краем уха слыхал о шаблонах. Поэтому открыл статью, чтобы понять базовые идеи и принципы. Все зашло без натуги и с первого раза, хотя в моем возрасте изучать что-то новое уже почти невозможно. Особенно ценной мне показалась информация о проблемах отладки. Я ведь использовать язык не планирую, и темой интересуюсь сугубо для расширения кругозора. Поэтому времени на системное изучение вопроса нет и не будет. А при беглом поиске по верхам такую сводку знаний вряд ли найдешь где-то еще!
А вопрос у меня такой: я правильно понял, что для обращения к элементам шаблонного массива надо использовать метод setElement и аналогичные? Но это же кошмарные накладные расходы, если надо перебирать элементы большого массива?! Или компилятор автоматически инлайнит все подобные обращения, в результате чего шаблонный массив будет так же эффективен, как встроенный?
Спасибо. Здорово что базовые идеи стали понятнее!
По поводу вызовов "setElement()" и "getElement()"... Инлайнинг никогда не гарантируется наверняка Чтобы максимально принудить инлайнить вызовы есть всякие специфичные для компиляторов ключевые слова (например, __forceinline).
Тем не менее, в таких тривиальных случаях при оптимизации выше "-O0" с вероятностью 99% компилятор выполнит инлайнинг.
Можно взять какой-нибудь простой код и посмотреть на godbolt.org как будет выполняться оптимизация:
Пример на простом коде
Упрощённый массив:
template<typename Type>
class SimpleArray
{
public:
SimpleArray(int inElementsNum)
: elements(new Type[inElementsNum]), num(inElementsNum)
{
}
Type getElement(int inIndex) const
{
return elements[inIndex];
}
void setElement(int inIndex, Type inValue)
{
elements[inIndex] = inValue;
}
~SimpleArray()
{
delete[] elements;
}
private:
Type* elements = nullptr;
int num = 0;
};
// -----------------------------------------------------
// Чтобы избежать лишних для нашего теста оптимизаций
// времени компиляции будем задавать количество элементов
// массива из количества элементов командной строки
int main(int argc, char *argv[])
{
//-- Int array --
SimpleArray<int> intArray{ argc };
for (int i = 0; i < argc; ++i)
intArray.setElement(i, i);
int sum1 = 0;
for (int i = 0; i < argc; ++i)
sum1 += intArray.getElement(i);
//-- Raw storage --
int* rawArray = new int[argc];
for (int i = 0; i < argc; ++i)
rawArray[i] = i;
int sum2 = 0;
for (int i = 0; i < argc; ++i)
sum2 += rawArray[i];
return sum1 + sum2;
}
Уже при минимальной оптимизации "-O1" видно что в ассемблерном коде нет call-вызовов методов:
Ассемблерный код с оптимизацией -O1
main:
push r13
push r12
push rbp
push rbx
sub rsp, 8
movsx r13, edi
movabs rax, 2305843009213693950
cmp r13, rax
ja .L2
mov r12d, edi
sal r13, 2
mov rdi, r13
call operator new[](unsigned long)
mov rbp, rax
lea ecx, [r12-1]
mov eax, 0
test r12d, r12d
jle .L21
.L5:
mov DWORD PTR [rbp+0+rax*4], eax
mov rdx, rax
add rax, 1
cmp rdx, rcx
jne .L5
mov rax, rbp
lea rdx, [rbp+4+rcx*4]
mov ebx, 0
.L6:
add ebx, DWORD PTR [rax]
add rax, 4
cmp rax, rdx
jne .L6
.L3:
mov rdi, r13
call operator new[](unsigned long)
jmp .L22
.L2:
call __cxa_throw_bad_array_new_length
.L21:
mov ebx, 0
jmp .L3
.L22:
test r12d, r12d
jle .L12
lea esi, [r12-1]
mov edx, 0
.L8:
mov DWORD PTR [rax+rdx*4], edx
mov rcx, rdx
add rdx, 1
cmp rsi, rcx
jne .L8
mov rdx, rax
lea rcx, [rax+4+rsi*4]
mov eax, 0
.L9:
add eax, DWORD PTR [rdx]
add rdx, 4
cmp rdx, rcx
jne .L9
.L7:
add ebx, eax
mov rdi, rbp
call operator delete[](void*)
mov eax, ebx
add rsp, 8
pop rbx
pop rbp
pop r12
pop r13
ret
.L12:
mov eax, 0
jmp .L7
mov rbx, rax
mov rdi, rbp
call operator delete[](void*)
mov rdi, rbx
call _Unwind_Resume
Для сравнения, вот вариант без оптимизации, в нём есть не заинлайненные функции и их вызовы (например, "call SimpleArray<int>::setElement(int, int)"):
Ассемблерный код с оптимизацией -O0
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 72
mov DWORD PTR [rbp-68], edi
mov QWORD PTR [rbp-80], rsi
mov edx, DWORD PTR [rbp-68]
lea rax, [rbp-64]
mov esi, edx
mov rdi, rax
call SimpleArray<int>::SimpleArray(int) [complete object constructor]
mov DWORD PTR [rbp-20], 0
.L3:
mov eax, DWORD PTR [rbp-20]
cmp eax, DWORD PTR [rbp-68]
jge .L2
mov edx, DWORD PTR [rbp-20]
mov ecx, DWORD PTR [rbp-20]
lea rax, [rbp-64]
mov esi, ecx
mov rdi, rax
call SimpleArray<int>::setElement(int, int)
add DWORD PTR [rbp-20], 1
jmp .L3
.L2:
mov DWORD PTR [rbp-24], 0
mov DWORD PTR [rbp-28], 0
.L5:
mov eax, DWORD PTR [rbp-28]
cmp eax, DWORD PTR [rbp-68]
jge .L4
mov edx, DWORD PTR [rbp-28]
lea rax, [rbp-64]
mov esi, edx
mov rdi, rax
call SimpleArray<int>::getElement(int) const
add DWORD PTR [rbp-24], eax
add DWORD PTR [rbp-28], 1
jmp .L5
.L4:
mov eax, DWORD PTR [rbp-68]
cdqe
movabs rdx, 2305843009213693950
cmp rax, rdx
ja .L6
sal rax, 2
mov rdi, rax
call operator new[](unsigned long)
jmp .L15
.L6:
call __cxa_throw_bad_array_new_length
.L15:
mov QWORD PTR [rbp-48], rax
mov DWORD PTR [rbp-32], 0
.L9:
mov eax, DWORD PTR [rbp-32]
cmp eax, DWORD PTR [rbp-68]
jge .L8
mov eax, DWORD PTR [rbp-32]
cdqe
lea rdx, [0+rax*4]
mov rax, QWORD PTR [rbp-48]
add rdx, rax
mov eax, DWORD PTR [rbp-32]
mov DWORD PTR [rdx], eax
add DWORD PTR [rbp-32], 1
jmp .L9
.L8:
mov DWORD PTR [rbp-36], 0
mov DWORD PTR [rbp-40], 0
.L11:
mov eax, DWORD PTR [rbp-40]
cmp eax, DWORD PTR [rbp-68]
jge .L10
mov eax, DWORD PTR [rbp-40]
cdqe
lea rdx, [0+rax*4]
mov rax, QWORD PTR [rbp-48]
add rax, rdx
mov eax, DWORD PTR [rax]
add DWORD PTR [rbp-36], eax
add DWORD PTR [rbp-40], 1
jmp .L11
.L10:
mov edx, DWORD PTR [rbp-24]
mov eax, DWORD PTR [rbp-36]
lea ebx, [rdx+rax]
lea rax, [rbp-64]
mov rdi, rax
call SimpleArray<int>::~SimpleArray() [complete object destructor]
mov eax, ebx
jmp .L16
mov rbx, rax
lea rax, [rbp-64]
mov rdi, rax
call SimpleArray<int>::~SimpleArray() [complete object destructor]
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L16:
add rsp, 72
pop rbx
pop rbp
ret
SimpleArray<int>::SimpleArray(int) [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov eax, DWORD PTR [rbp-12]
cdqe
movabs rdx, 2305843009213693950
cmp rax, rdx
ja .L18
sal rax, 2
jmp .L20
.L18:
call __cxa_throw_bad_array_new_length
.L20:
mov rdi, rax
call operator new[](unsigned long)
mov rdx, rax
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rbp-12]
mov DWORD PTR [rax+8], edx
nop
leave
ret
SimpleArray<int>::~SimpleArray() [base object destructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
test rax, rax
je .L23
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdi, rax
call operator delete[](void*)
.L23:
nop
leave
ret
SimpleArray<int>::setElement(int, int):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov DWORD PTR [rbp-16], edx
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov edx, DWORD PTR [rbp-12]
movsx rdx, edx
sal rdx, 2
add rdx, rax
mov eax, DWORD PTR [rbp-16]
mov DWORD PTR [rdx], eax
nop
pop rbp
ret
SimpleArray<int>::getElement(int) const:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov edx, DWORD PTR [rbp-12]
movsx rdx, edx
sal rdx, 2
add rax, rdx
mov eax, DWORD PTR [rax]
pop rbp
ret
А если я пишу вот так:
auto max(auto a, auto b) {
return (a >= b ? a : b);
}
Это шаблоны или что? И, если это не шаблоны, то чем отличается от:
template<typename T>
T max(T a, T b) {
return (a >= b ? a : b);
}
Это шаблоны или что?Начиная с C++20 это сокращение для шаблона (ничем не отличается от варианта ниже, разве что T может быть свой для каждого аргумента и возвращаемого значения). В предыдущих версиях это невалидный код.
Это так можно писать почти как на питоне, везде auto и утиная типизация, вместо интерфейсного наследования.
Всё типы всё равно будут определены на компиляции, а значит это не будет также как на питоне. Ну и конечно возвращать нужно не auto а decltype(auto), ведь может оказаться и ссылка в общем случае.
Ну и разница большая, версия с auto эквивалентна
template<typename T, typename U>
auto max(T a, U b);
И важно понимать, что auto в возвращаемом типе никак не влияет на то что это шаблон, а вот в принимаемых аргументах - влияет. Ну это достаточно очевидно, но тем не менее иногда новички думают что auto это вообще динамическая типизация
Шаблоны, но отличается: в первом примере у вас типы аргументов могут не совпадать, а тип возвращаемого значения выводится автоматически по правилам для тернарного оператора. Во втором примере сначала выводится тип аргументов (должен быть один для двух аргументов), а потом он же становится возвращаемым типом.
Тема SFINAE не раскрыта!
1) Можно было бы упомянуть о шаблонных шаблонных параметрах — где параметром шаблона является шаблон. В 1-м комментарии об этом упоминается. Возможно это будет в других статьях.
2) Для меня, как не сильного спеца по плюсам впечатлила книга Андрея Александреску «Современное проектирование на C++» — первая часть книги для меня была магической.
В стандартной библиотеке шаблонов эту структуру данных реализует шаблон класса "std::vector<>".
Скорее std::array
Ну, на тот момент ещё std::vector, так как размер там в рантайме передаётся. Про std::array в разделе про шаблонные аргументы-константы написано, где размер определяется в компайлтайм.
П.С.: В целом, спасибо что обратили внимание. Я с ремаркой про C++20 о std::span там напишу ещё, он ближе всего по смыслу.
const int max = max<int>(abMax, c);
Имя переменной выбрано неудачно. Компилятор думает, что мы пытаемся работать с переменной как с функцией.
Я, кстати, не понял почему утащило
Я бы сказал так, что шаблоны — это на самом деле совсем не шаблоны… На самом деле C++ — это несколько отдельных, по меньшей мере три, языка программирования:
1. язык C-препроцессора осуществляющий подстановку на уровне текста — работает даже не во время, а до компиляции;
2. усовершенствованная версия языка C с классами;
3. декларативный язык программирования, программы для которого исполняются в момент компиляции, который оперирует несколькими ортогональными понятиями, по меньшей мере существует пространство типов (классов), пространство численных значений (констант) и функций.
Результатом работы упомянутого декларативного языка является генерация некой условной, в явном виде не отображаемой программисту, программы для C-с-классами (подробности можно подглядеть на https://cppinsights.io/).
Основными функциями этого воображаемого декларативного языка, как части C++, являются:
- преобразование типов (неявное, с использованием пользовательских функций преобразования типов, подстановка конструкторов);
- статическая диспетчеризация, function lookup, зависимая от типов аргументов, поддержка концепции SFINAE;
- и самое главное, собственно вишенка на торте: шаблоны.
Все эти функции выполняются совершенно явно в декларативном порядке: компилятор сам ищет решение и либо находит его, либо не находит, либо находит множественные решения которые допустимы (например, более специализированная шаблонная функция), или не допустимы (например, перегрузка позволяет вызвать более одной функции, что вызывает неоднозначность).
Шаблоны, на мой взгляд, в данном случае следует скорей рассматривать как набор деклараций, правил, по которым могут быть выведены новые функции и, самое главное новые типы. Можно сказать, что шаблоны C++ — это декларативный язык выполняющий вычисления в пространстве типов.
Важно понимать, что подразумевается под понятием типа. Тип — это не описание того, как данные хранятся в памяти (структура), это пока не важно (это важно будет для компилятора C с классами). Важно что тип может обладать некоторым набором свойств. В частности вложенными типами, вложенными правилами вывода типов и т.д. и т.п. И на самом деле в пространстве типов попросту возможно программирование, вся программа может целиком исполняться в процессе компиляции, а не после компиляции. Конечно программирование в таком виде не слишком удобно и не предназначено для решения прикладных задач, а предназначено для задач метапрограммирования.
На самом деле конечно описанный декларативный язык не совсем декларативный. Некоторые вещи, например Argument Dependent Lookup начинает зависеть от порядка и видимости деклараций, раскрытие шаблонов тоже. В частности существует такая штука как type loophole которая попросту паразитирует на том факте, что порядок деклараций в C++ всё же имеет значение…
Возвращаясь к шаблонам. Шаблоны названы так исторически. Технически шаблон это совершенно не подстановка текста. Подстановка текста — это C-препроцессор, из чего изначально шаблоны родились. Шаблоны — это именно набор деклараций, используя которые компилятор самостоятельно ищет решение. И в вашей статье описывается, мол есть шаблоны классов, шаблоны функций, шаблоны переменных наконец (начиная с C++14). Но это всё на самом деле не слишком принципиально. Это скорей историческое наследие: типичный пример использования шаблона функций сейчас в метапрограммировании — это отнюдь не генерация функции, а использование автоматического вывода типов. Даже без тела собственно самой функции. Чтоб в шаблоне функции попросту получить типы в виде параметров шаблона (их компилятор автоматически выведет) и использовать их где-то дальше, например для параметризации уже другого шаблона класса. Да, шаблоны можно использовать и для генерации классов, и для генерации функций и переменных, но это давно не единственное их предназначение. Основным я бы назвал реализацию приёмов метапрограммирования.
Процитирую некоторые моменты:
В терминах C++ обобщённое описание функции называется шаблоном функции.
Да и нет. В силу возможности специализации для конкретных параметров шаблона, важным отличием шаблонов в C++ от дженериков в C# и других языках является то, что собственно сами специализации могут быть кардинально разными в зависимости от параметров. Т.е. это не просто подстановка типа, Функция просто может делать совсем что-то другое. Или, в случае специализации класса, специализация для конкретного типа, например, может породить что-то совершенно отличное от обощённой специализации. Данная практика широко используется для задач метапрограмирования.
Так же стоит сказать о порождении класса из шаблона. Принципиальным является то, что класс из шаблона появляется в момент его первого использования (фактически, это в каком-то смысле ленивое вычисление в пространстве типов). И потом уже существует до конца работы компилятора. При определённых обстоятельствах факт генерирования нового типа из шаблона («инстанциации» шаблона) может иметь побочные эффекты и их можно обнаружить. И самое важное, типы от которых шаблон зависит тоже будут востребованы в момент генерации типа из шаблона, а не в момент парсинга шаблона компилятором. В момент парсинга компилятор вынужден работать с определениями которые ещё не определены (отсюда и нужда руками выписывать ключевые слова typename и template внутри шаблона, отсюда же и необходимость создания алиасов для типов, которые до генерации типа из шаблона не видны в пространстве имён, например в случае наследования от класса заданного в параметрах шаблона).
Нужно заметить, что концепты сами по себе не является каким-то новым свойством, по сути это такой синтакс-сахар для удобной замены std::enable_if. Ключевым моментом здесь является не концепт, а сама концепция SFINAE реализуемая C++-компилятором и не реализуемая во многих других языках. Как было сказано выше, компилятор ищет решение, возможную подстановку, и может при этом откинуть некорректные варианты без ошибки.
Не рассмотрен такой важный вопрос, что в C++ параметром шаблона может являться другой шаблон. Это тоже принципиальное отличие C++ от других языков с обобщёнными функциями (generics). Шаблон принявший другой шаблон сам может параметризовать его как ему нужно и начать использовать полученные из этого шаблона функции и типы. Без такого функционала, метапрограммирование в C++ было бы очень ограничено.
Так же опущен вопрос переменного числа аргументов в параметрах шаблона. Это — важный функцинал, он позволяет рекурсиивные вычисления в простанстве типов. И в частности можно обрабатывать списки, что часто практически очень удобно.
Процитирую ещё:
Макросы выполняют текстовую подстановку аргументов, в то время как шаблоны лексически и синтаксически проверяются компилятором
НЕТ. Шаблоны не осуществляют просто подстановку. Только в простейших случаях. Но в более общем случае, шаблоны — это декларативный язык который описывает, как может быть сгенерирован уже настоящий код (классы, функции и т.п.) Этот момент крайне важно понимать, иначе метапрограммирование не будет даваться. На одних подстановках далеко не уедешь.
Затрудняют ли шаблоны отладку кода?
Пошаговая отладка, например в gdb, или в VisualStudio, сколько-нибудь сложного кода с шаблонами скорей не реальна. Отладчик конечно будет отлично шагать по сгенерированным из шаблона функциям, проблема не в этом. Проблема в том, чтоб шаблонный код управляет работой компилятора, и сгенерировано в итоге может быть что-то совсем не то, на что расчитывал программист. Ошибка может быть такая, что её при пошаговой отладке просто будет невозможно понять. Потому, что шаблоны — это процесс компиляции, а не исполнения.
Если нужно отладить шаблоны, то как правило есть две проблемы:
1. ошибка компиляции — для gcc или clang сейчас вывод компилятора достаточно хорош, показывается весь путь раскрытия шаблонов, параметры шаблонов. Ситуация реально лучше, чем было лет 10 тому назад. Компиляторы не полностью разворачивают всю историю, и если не хватает вывода, то можно использовать опцию -ftemplate-backtrace-limit.
2. непонятно что вообще получается, ошибки допустим нет. В такой ситуации можно использовать metashell для того, чтобы понять во что превращаются шаблоны. Впрочем часто достаточно спровоцировать ошибку и посмотреть вывод компилятора…
PS: в статье на мой взгляд слишком длинные и нудные примеры, что затрудняет понимание новичками. Примеры по-моему лучше сделать короткими, и заодно помимо исторических использований шаблонов показать основные ключевые приёмы метапрограммирования. Например такие как std::void_t, std::integer_sequence, std::enable_if и ключевое слово decltype (чего так же нет во многих других других языках, а в C существует в виде нестандартного typeof), std::declval (который в голом C спрятан в макросе offsetof), можно показать, как шаблоны могут использоваться для вывода возвращаемого значения и типов аргументов другой функции и тому подобные вещи…
Статья классная, спасибо. Уже начал ждать вторую часть ;)
Спасибо за статью, было интересно читать - особенно код примеров. Жду продолжение
Просто о шаблонах C++