Другое

Шаблоны C++: За пределами простой подстановки текста

Узнайте правду о шаблонах C++ и подстановке текста. Узнайте, почему компиляция шаблонов сложнее простой замены текста, и поймите ключевые различия между шаблонами и макросами.

Являются ли шаблоны C++ просто текстовой подстановкой, выполняемой компилятором?

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

Моё текущее понимание

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

Пример кода

cpp
struct StringableThing1 {
    std::string to_string() { return std::string("hello world"); }
};

struct StringableThing2 {
    std::string name;
    std::string to_string() { return name; }
};

struct NotAStringableThing {
    std::vector<std::byte> data;
    std::vector<std::byte> to_binary_string() { return data; }
};

template<typename Stringable>
void do_something(Stringable something) {
    std::string some_string = something.to_string();
    std::println(some_string);
}

// работает
StringableThing1 thing1;
do_something(thing1);

// работает
StringableThing2 thing2;
do_something(thing2);

// не работает
NotAStringableThing thing3;
do_something(thing3);

В этом примере do_something - это шаблонная функция с параметром шаблона Stringable. Код внутри этой функции накладывает неявное требование, чтобы любой тип Stringable имел метод to_string(), который не принимает аргументов и возвращает std::string.

Расширенный пример

cpp
template<typename Stringable>
void do_something(Stringable something) {
    std::string some_string = something.to_string();
    std::println(some_string);
    std::vector<std::byte> some_binary_string = something.to_binary_string();
    std::println(binary_string_to_utf8(some_binary_string));
}

В этой расширенной версии ни один из трёх примерных типов не работает полностью:

  • Первые две строки компилируются с StringableThing1 и StringableThing2, но последние две - нет.
  • Последние две строки компилируются с NotAStringableThing, но первые две - нет.

Вопрос

Является ли это понимание шаблонов как простой текстовой подстановки точным? Если да, то почему на практике шаблоны часто кажутся более сложными, чем это?

Содержание


Основное различие: Шаблоны против текстовой подстановки

Ваша ментальная модель не совсем неверна, но она неполна. Шаблоны действительно включают подстановку, но это осведомленная о типах подстановка, которая происходит на этапе компиляции, а не простая текстовая замена. Как объясняется в одном из ответов на Stack Overflow, “хотя параметры шаблонов проверяются на типы, и у шаблонов есть много преимуществ перед макросами, шаблоны очень похожи на макросы в том, что они все еще основаны на текстовой подстановке”.

Однако ключевое различие заключается во времени и осведомленности:

  • Макросы выполняют простую текстовую подстановку на этапе предварительной обработки
  • Шаблоны выполняют осведомленную о типах подстановку на этапе компиляции

Software Engineering Stack Exchange уточняет это: “C-макросы расширяются на этапе предварительной обработки, до начала любой другой компиляции, тогда как C+±шаблоны являются частью компиляции. Это означает, что C+±шаблоны осведомлены о типах и имеют область видимости, среди прочих особенностей.”


Как на самом деле работает компиляция шаблонов

Когда компилятор встречает использование шаблона, например do_something(thing1), вот что на самом деле происходит:

  1. Распознавание шаблона: Компилятор определяет, что do_something - это шаблонная функция
  2. Вывод аргументов: Компилятор определяет, что Stringable должен быть StringableThing1 на основе типа аргумента
  3. Инстанцирование: Компилятор создает конкретную версию функции, подставляя StringableThing1 вместо Stringable
  4. Компиляция: Инстанцированный код компилируется с полной проверкой типов

Как объясняется в документации cppreference по выводу аргументов шаблона, “вывод аргументов шаблона пытается определить аргументы шаблона… которые могут быть подставлены в каждый параметр P для получения выведенного типа A, который совпадает с типом аргумента A”.

Этот процесс сильно отличается от простой текстовой подстановки, потому что компилятор понимает типы, области видимости и семантическое значение во время процесса подстановки.


Почему шаблоны кажутся сложнее простой текстовой подстановки

Ваш расширенный пример прекрасно иллюстрирует, почему шаблоны - это не просто простая текстовая подстановка. Давайте разберем, что происходит с вашим расширенным шаблоном:

cpp
template<typename Stringable>
void do_something(Stringable something) {
    std::string some_string = something.to_string();           // Строка 1
    std::println(some_string);                                // Строка 2
    std::vector<std::byte> some_binary_string = something.to_binary_string(); // Строка 3
    std::println(binary_string_to_utf8(some_binary_string));   // Строка 4
}

Для StringableThing1 и StringableThing2:

  • Строки 1-2 успешно компилируются, потому что эти типы имеют to_string()
  • Строки 3-4 не компилируются, потому что эти типы не имеют to_binary_string()

Для NotAStringableThing:

  • Строки 1-2 не компилируются, потому что этот тип не имеет to_string()
  • Строки 3-4 успешно компилируются, потому что этот тип имеет to_binary_string()

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


Анализ ваших примеров кода

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

cpp
template<typename Stringable>
void do_something(Stringable something) {
    std::string some_string = something.to_string();           // Строка 1
    std::println(some_string);                                // Строка 2
    std::vector<std::byte> some_binary_string = something.to_binary_string(); // Строка 3
    std::println(binary_string_to_utf8(some_binary_string));   // Строка 4
}

Для StringableThing1 и StringableThing2:

  • Строки 1-2 успешно компилируются, потому что эти типы имеют to_string()
  • Строки 3-4 не компилируются, потому что эти типы не имеют to_binary_string()

Для NotAStringableThing:

  • Строки 1-2 не компилируются, потому что этот тип не имеет to_string()
  • Строки 3-4 успешно компилируются, потому что этот тип имеет to_binary_string()

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


Ключевые различия между шаблонами и макросами

Особенность Шаблоны Макросы
Время выполнения Во время компиляции Во время предварительной обработки
Осведомленность о типах Полная проверка типов Отсутствует (только текст)
Область видимости Соблюдает правила области видимости C++ Игнорирует область видимости
Сообщения об ошибках Контекстные, специфичные для типа Общие текстовые ошибки
Отладка Можно отлаживать инстанцированный код Нельзя отлаживать код после расширения макроса

Как объясняется в одном из источников, “Макросы - это глупый механизм простой текстовой подстановки; они не имеют представления об области видимости и типах, что может приводить к странным семантическим ошибкам.”


Принцип SFINAE

Ваши примеры также затрагивают принцип Substitution Failure Is Not An Error (SFINAE), который является ключевым аспектом компиляции шаблонов и выходит далеко за рамки простой текстовой подстановки.

Как объясняется в Wikipedia, “Если во время подстановки набора аргументов для любого данного шаблона возникает ошибка, компилятор удаляет потенциальную перегрузку из набора кандидатов вместо того, чтобы остановиться с ошибкой компиляции.”

Это означает, что когда подстановка шаблона не удается (например, когда тип не имеет требуемого члена-функции), компилятор не сразу генерирует ошибку. Вместо этого он считает инстанциацию шаблона недействительной и может尝试 другие перегрузки или альтернативы.


Практические последствия использования шаблонов

Сложность шаблонов обусловлена несколькими факторами:

  1. Двухфазная компиляция: Шаблоны проверяются на синтаксис во время определения шаблона, но семантика проверяется только во время инстанциации. Именно поэтому можно иметь шаблоны, которые успешно компилируются до тех пор, пока не используются с определенными типами.

  2. Поиск имен: Доступ к членам шаблона включает сложные правила поиска, которые зависят от того, являются ли имена зависимыми или независимыми.

  3. Специализация шаблонов: Вы можете предоставлять пользовательские реализации для конкретных типов, добавляя еще один уровень сложности.

  4. Концепции (современный C++): C++20 вводит концепции, которые делают шаблоны более ограниченными и понятными, но добавляют еще один слой в процесс компиляции.

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


Заключение

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

  2. Сложность возникает из-за: Проверки типов, правил поиска имен, SFINAE, специализации шаблонов и процесса двухфазной компиляции.

  3. Ваше понимание частично верно, но упускает ключевые аспекты осведомленности о типах и обработки на этапе компиляции, которые делают шаблоны принципиально отличными от макросов.

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

  5. Современный C++ с концепциями и другими особенностями делает шаблоны более доступными, сохраняя их мощные возможности безопасного обобщенного программирования.

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


Источники

  1. Are C++ Templates just Macros in disguise? - Stack Overflow
  2. Are C++ templates just a kind of glorified macros? - Software Engineering Stack Exchange
  3. Template argument deduction - cppreference.com
  4. Introduction to C++ Templates - DEV Community
  5. Substitution failure is not an error - Wikipedia
  6. Comparing the Compilation Times of C++ Templates and Macros - HackerNoon
  7. History of C++ Templates: from C-style macros to concepts - Belay the C++
Авторы
Проверено модерацией
Модерация