Skip to content

Instantly share code, notes, and snippets.

@MangaD
Last active April 18, 2026 03:57
Show Gist options
  • Select an option

  • Save MangaD/2f9b4fca1811e35e7986c6ac040259e4 to your computer and use it in GitHub Desktop.

Select an option

Save MangaD/2f9b4fca1811e35e7986c6ac040259e4 to your computer and use it in GitHub Desktop.
C++: Template argument deduction, CTAD, and related

Template Argument Deduction in C++: A Comprehensive Guide

CC0

Disclaimer: Grok generated document.

Introduction

Template argument deduction is a core feature of C++ that allows the compiler to automatically infer the template parameters of a function or class template based on the arguments provided during a call or instantiation. This mechanism simplifies the use of templates, making generic code more concise and user-friendly. Since C++17, Class Template Argument Deduction (CTAD) has extended this capability to class templates. Combined with default template parameters, deduction enables flexible and robust generic programming. This article provides a thorough exploration of template argument deduction, its interaction with default template parameters, its application in function and class templates, and advanced techniques like SFINAE, tag dispatching, and C++20 concepts. It also addresses why partial specialization is not allowed for function templates.

1. Overview of Template Argument Deduction

Template argument deduction enables the compiler to determine template parameters (types or values) based on the context of a template’s use. It applies to:

  • Function Templates: Deduction infers parameters from function call arguments (since C++98).
  • Class Templates: CTAD deduces parameters from constructor arguments or aggregate initialization (since C++17).
  • Default Template Parameters: Provide fallback types or values when parameters are not deduced or explicitly specified.

Deduction is essential for writing reusable, type-safe code without requiring explicit template argument specification in most cases.

2. Function Template Argument Deduction

Function template deduction, introduced in C++98, infers template parameters by matching function call arguments to the template’s parameter types.

2.1 Basic Deduction

Consider a simple function template:

#include <iostream>

template <typename T>
T add(T a, T b) {
    std::cout << "Deduced T=" << typeid(T).name() << "\n";
    return a + b;
}

int main() {
    add(1, 2);     // Deduced: T = int
    add(1.5, 2.5); // Deduced: T = double
    return 0;
}

Output (may vary by compiler):

Deduced T=i
Deduced T=d
  • The compiler deduces T as int for add(1, 2) and double for add(1.5, 2.5) based on the argument types.
  • Deduction fails if types conflict (e.g., add(1, 2.5)), as T must be consistent across all parameters.

2.2 Deduction Rules

  • Exact Match: The compiler prefers exact type matches. Standard conversions (e.g., int to double) are not applied during deduction unless handled via overloads.
  • References and Qualifiers:
    • For parameters like T& or T&&, deduction considers the argument’s value category (lvalue/rvalue) and qualifiers (const, volatile).
    template <typename T>
    void func(T& x) {}
    
    int x = 10;
    func(x); // Deduced: T = int (not int&)
    • For universal references (T&&):
      template <typename T>
      void func(T&& x) {}
      
      int x = 10;
      func(x);   // Deduced: T = int& (lvalue)
      func(10);  // Deduced: T = int (rvalue)
  • Multiple Parameters: All parameters must deduce consistent types, or deduction fails.
  • SFINAE (Substitution Failure Is Not An Error): If deduction fails for a template, the compiler discards it and tries other candidates (overloads or specializations).
  • Explicit Specification: Explicitly specifying template arguments (e.g., add<int>(1, 2)) bypasses deduction.

2.3 Deduction with Default Template Parameters

Default template parameters provide fallback types or values when parameters are not deduced or explicitly specified. For function templates, defaults are used when:

  • No arguments are provided, and the function has default arguments.
  • The user omits a template parameter, and deduction is not possible.

Example: Deduction with Defaults

#include <iostream>

template <typename T = int>
T process(T x = T()) {
    std::cout << "Processing: " << x << " (T=" << typeid(T).name() << ")\n";
    return x;
}

int main() {
    process(1.5);     // Deduced: T = double
    process<int>(42); // Explicit: T = int
    process();        // Default: T = int, x = 0
    return 0;
}

Output (may vary by compiler):

Processing: 1.5 (T=d)
Processing: 42 (T=i)
Processing: 0 (T=i)
  • Line process(1.5): Deduction infers T = double, overriding the default T = int.
  • Line process<int>(42): Explicit specification of T = int bypasses deduction.
  • Line process(): No argument is provided, so the default argument x = T() (default-constructed T) and default template parameter T = int are used, resulting in x = 0.

2.4 Deduction with Full Specialization

Deduction occurs before selecting a specialization. If the deduced types match a full specialization, it is used; otherwise, the primary template applies.

Example: Deduction with Specialization

#include <iostream>

template <typename T = int>
T process(T x = T()) {
    std::cout << "Primary: " << x << " (T=" << typeid(T).name() << ")\n";
    return x;
}

template <>
double process<double>(double x) {
    std::cout << "Specialized: " << x << " (T=double)\n";
    return x + 0.5;
}

int main() {
    process(1.5);     // Deduced: T = double, uses specialization
    process<int>(42); // Explicit: T = int, uses primary template
    process();        // Default: T = int, uses primary template
    return 0;
}

Output (may vary by compiler):

Specialized: 1.5 (T=double)
Primary: 42 (T=i)
Primary: 0 (T=i)

2.5 Why Partial Specialization Is Not Allowed for Function Templates

Partial specialization is prohibited for function templates due to C++’s reliance on overload resolution and template argument deduction, which would conflict with partial specialization:

  1. Conflict with Overload Resolution:

    • Function templates are resolved via overload resolution, ranking candidates based on argument matching. Partial specialization would introduce a competing mechanism, potentially causing ambiguity.
    • Hypothetical (invalid) example:
      template <typename T, typename U>
      void func(T, U) {}
      
      template <typename T>
      void func<T, int>(T, int) {} // Error: Partial specialization not allowed
    • Should this be treated as a specialization or an overload? Prohibiting partial specialization avoids ambiguity.
  2. Deduction Complexity:

    • Deduction matches arguments to parameter types. Partial specialization would complicate this, especially with default parameters, as the compiler would need to reconcile specialization matching with deduction.
    • Hypothetical example:
      template <typename T = int, typename U>
      void func(T, U) {}
      
      template <typename T>
      void func<T, int>(T, int) {} // How would defaults interact?
  3. Historical Design:

    • Function templates were designed to integrate with overload resolution, a mechanism used for non-template functions. Partial specialization was deemed unnecessary, as overloading and SFINAE provide similar functionality.
  4. Compiler Complexity:

    • Supporting partial specialization for functions would require handling both specialization matching and overload resolution, increasing compiler complexity.

Alternatives to Partial Specialization

C++ provides alternatives to achieve partial specialization-like behavior for function templates:

  1. Overloading:

    • Overload templates to handle specific cases.
    • Example:
      #include <iostream>
      
      template <typename T = int, typename U = int>
      void func(T x, U y = U()) {
          std::cout << "Primary: " << x << ", " << y << "\n";
      }
      
      template <typename T>
      void func(T x, int y) {
          std::cout << "Overload for int: " << x << ", " << y << "\n";
      }
      
      int main() {
          func(1.5, 2);   // Overload: T = double, y = int
          func(1.5, 2.5); // Primary: T = double, U = double
          func(1);        // Primary: T = int, U = int
          return 0;
      }
      Output (may vary by compiler):
    Overload for int: 1.5, 2
    Primary: 1.5, 2.5
    Primary: 1, 1
    
  2. SFINAE:

    • Use SFINAE to enable/disable templates based on type traits.
    • Example:
      #include <type_traits>
      #include <iostream>
      
      template <typename T = int, typename U, std::enable_if_t<!std::is_integral_v<U>, int> = 0>
      void func(T x, U y) {
          std::cout << "Non-integral U: " << x << ", " << y << "\n";
      }
      
      template <typename T = int, typename U, std::enable_if_t<std::is_integral_v<U>, int> = 0>
      void func(T x, U y) {
          std::cout << "Integral U: " << x << ", " << y << "\n";
      }
      
      int main() {
          func(1, 2.5); // Non-integral U
          func(1, 2);   // Integral U
          return 0;
      }
      Output (may vary by compiler):
    Non-integral U: 1, 2.5
    Integral U: 1, 2
    
  3. Tag Dispatching:

    • Use distinct tag types to dispatch to different implementations based on type properties.
    • Example:
      #include <type_traits>
      #include <iostream>
      
      struct IntegralTag {};
      struct NonIntegralTag {};
      
      template <typename T = int, typename U>
      void func_impl(T x, U y, IntegralTag) {
          std::cout << "Integral U: " << x << ", " << y << "\n";
      }
      
      template <typename T = int, typename U>
      void func_impl(T x, U y, NonIntegralTag) {
          std::cout << "Non-integral U: " << x << ", " << y << "\n";
      }
      
      template <typename T = int, typename U>
      void func(T x, U y) {
          if constexpr (std::is_integral_v<U>) {
              func_impl(x, y, IntegralTag{});
          } else {
              func_impl(x, y, NonIntegralTag{});
          }
      }
      
      int main() {
          func(1, 2.5); // Non-integral U
          func(1, 2);   // Integral U
          return 0;
      }
      Output (may vary by compiler):
    Non-integral U: 1, 2.5
    Integral U: 1, 2
    
    • This example uses IntegralTag and NonIntegralTag to disambiguate overloads, with if constexpr ensuring the correct func_impl is called based on std::is_integral_v<U>.
  4. C++20 Concepts:

    • Concepts constrain templates cleanly, replacing SFINAE in many cases.
    • Example:
      #include <concepts>
      #include <iostream>
      
      template <typename T = int, std::integral U>
      void func(T x, U y) {
          std::cout << "Integral U: " << x << ", " << y << "\n";
      }
      
      template <typename T = int, typename U>
      void func(T x, U y) {
          std::cout << "Generic U: " << x << ", " << y << "\n";
      }
      
      int main() {
          func(1, 2);   // Integral U
          func(1, 2.5); // Generic U
          return 0;
      }
      Output (may vary by compiler):
    Integral U: 1, 2
    Generic U: 1, 2.5
    

3. Class Template Argument Deduction (CTAD, C++17)

Since C++17, Class Template Argument Deduction (CTAD) allows the compiler to deduce template parameters during object construction, eliminating the need for explicit template arguments.

3.1 Basic CTAD

CTAD deduces types from constructor arguments or aggregate initialization.

Example: CTAD with Class Template

#include <iostream>

template <typename T = int>
struct Box {
    T value;
    Box(T v) : value(v) {
        std::cout << "Primary: T=" << typeid(T).name() << "\n";
    }
};

int main() {
    Box b(42);   // CTAD: T = int
    Box d(3.14); // CTAD: T = double
    Box<int> i(10); // Explicit: T = int
    return 0;
}

Output (may vary by compiler):

Primary: T=i
Primary: T=d
Primary: T=i
  • CTAD infers T from the constructor argument (42 β†’ int, 3.14 β†’ double).
  • Explicit specification (Box<int>) bypasses CTAD.

3.2 CTAD with Deduction Guides

Deduction guides (implicit or user-defined) specify how to deduce template parameters for complex cases.

Example: Deduction Guide with Multiple Parameters

#include <iostream>

template <typename T, typename U>
struct Pair {
    T first;
    U second;
    Pair(T f, U s) : first(f), second(s) {}
};

// Deduction guide
template <typename T, typename U>
Pair(T, U) -> Pair<T, U>;

int main() {
    Pair p(42, 3.14); // CTAD: Pair<int, double>
    std::cout << "T=" << typeid(decltype(p.first)).name() << ", U=" << typeid(decltype(p.second)).name() << "\n";
    return 0;
}

Output (may vary by compiler):

T=i, U=d

3.3 CTAD with Defaults

Default template parameters are used when CTAD cannot deduce a parameter.

Example: CTAD with Defaults

#include <iostream>

template <typename T = int, int N = 10>
struct Array {
    T data[N];
    Array(T v) {
        for (int i = 0; i < N; ++i) data[i] = v;
        std::cout << "Array: T=" << typeid(T).name() << ", N=" << N << "\n";
    }
};

int main() {
    Array a(1); // CTAD: T = int, N = 10 (default)
    return 0;
}

Output (may vary by compiler):

Array: T=i, N=10
  • CTAD deduces T = int from 1, and N uses the default 10.

3.4 CTAD with Specialization

CTAD works with full specializations, selecting them based on deduced types.

Example: CTAD with Specialization

#include <iostream>

template <typename T = int>
struct Box {
    Box(T) { std::cout << "Primary: T=" << typeid(T).name() << "\n"; }
};

template <>
struct Box<double> {
    Box(double) { std::cout << "Specialized: T=double\n"; }
};

int main() {
    Box b(42);   // CTAD: T = int, primary template
    Box d(3.14); // CTAD: T = double, specialization
    return 0;
}

Output (may vary by compiler):

Primary: T=i
Specialized: T=double

4. Interaction with Default Template Parameters

Default template parameters enhance deduction by providing fallbacks when parameters are not deduced or specified.

4.1 Function Templates

  • Defaults apply when arguments are omitted (with default function arguments) or not deduced.
  • Deduction takes precedence over defaults when arguments are provided.

Example: Function Template with Defaults

#include <iostream>

template <typename T = int, typename U = double>
void pair(T x = T(), U y = U()) {
    std::cout << "Pair: x=" << x << " (T=" << typeid(T).name() << "), y=" << y << " (U=" << typeid(U).name() << ")\n";
}

int main() {
    pair(1, 2.5); // Deduced: T = int, U = double
    pair(1);      // Deduced: T = int, U = double (default for y)
    pair();       // Default: T = int, U = double
    pair<double, int>(1.5, 2); // Explicit: T = double, U = int
    return 0;
}

Output (may vary by compiler):

Pair: x=1 (T=i), y=2.5 (U=d)
Pair: x=1 (T=i), y=0 (U=d)
Pair: x=0 (T=i), y=0 (U=d)
Pair: x=1.5 (T=d), y=2 (U=i)

4.2 Class Templates

  • Defaults apply when CTAD cannot deduce a parameter or arguments are omitted.

Example: CTAD with Defaults

#include <iostream>

template <typename T = int, typename U = double>
struct Pair {
    T first;
    U second;
    Pair(T f, U s = U()) : first(f), second(s) {
        std::cout << "Pair: T=" << typeid(T).name() << ", U=" << typeid(U).name() << "\n";
    }
};

int main() {
    Pair p(42); // CTAD: T = int, U = double (default for second)
    return 0;
}

Output (may vary by compiler):

Pair: T=i, U=d

5. Advanced Techniques

5.1 SFINAE with Deduction

SFINAE enables templates based on type traits, controlling deduction outcomes.

Example: SFINAE with Defaults

#include <type_traits>
#include <iostream>

template <typename T = int, typename U, std::enable_if_t<std::is_integral_v<U>, int> = 0>
void func(T x, U y) {
    std::cout << "Integral U: " << x << ", " << y << "\n";
}

template <typename T = int, typename U, std::enable_if_t<!std::is_integral_v<U>, int> = 0>
void func(T x, U y) {
    std::cout << "Non-integral U: " << x << ", " << y << "\n";
}

int main() {
    func(1, 2);   // Integral U
    func(1, 2.5); // Non-integral U
    return 0;
}

Output (may vary by compiler):

Integral U: 1, 2
Non-integral U: 1, 2.5

5.2 C++20 Concepts with Deduction

Concepts constrain templates cleanly, simplifying deduction and specialization.

Example: Concepts with Defaults

#include <concepts>
#include <iostream>

template <typename T = int, std::integral U>
void func(T x, U y) {
    std::cout << "Integral U: " << x << ", " << y << "\n";
}

template <typename T = int, typename U>
void func(T x, U y) {
    std::cout << "Generic U: " << x << ", " << y << "\n";
}

int main() {
    func(1, 2);   // Integral U
    func(1, 2.5); // Generic U
    return 0;
}

Output (may vary by compiler):

Integral U: 1, 2
Generic U: 1, 2.5

6. Practical Considerations

  • Defaults: Choose sensible defaults to avoid unexpected behavior. Ensure they align with typical use cases.
  • Deduction Testing: Test deduction with various argument types, defaults, and explicit specifications to ensure correctness.
  • SFINAE/Concepts: Use SFINAE or concepts for complex deduction scenarios, especially for function templates.
  • CTAD: Provide deduction guides for class templates with complex initialization patterns.
  • Tag Dispatching: Use distinct tag types (as in the corrected example) to ensure clear overload resolution.

7. Common Pitfalls

  • Conflicting Deduction:
    template <typename T>
    void func(T x, T y) {}
    
    func(1, 2.5); // Error: T cannot be both int and double
  • Missing Arguments:
    • Without default function arguments, calls without arguments fail:
      template <typename T = int>
      void func(T x) {}
      
      func(); // Error: No argument provided
  • Invalid Defaults:
    • Defaults cannot depend on unresolved properties (e.g., T::value_type).

8. Conclusion

Template argument deduction is a powerful feature that simplifies the use of templates in C++ by automatically inferring parameters from arguments. Default template parameters enhance this by providing fallbacks when parameters are not deduced or specified. Function template deduction, combined with overloading, SFINAE, tag dispatching, or concepts, and CTAD for class templates enable expressive generic programming. The corrected tag dispatching example demonstrates a robust approach to handling type-based specialization. By mastering these mechanisms, developers can write concise, robust, and reusable C++ code.


Examples

CC0

Disclaimer: ChatGPT generated document.

πŸš€ GOAL

We are trying to understand template type deduction in C++, especially how the compiler deduces the type T in the following functions:

template<typename T>
void foo1(const T& t);

template<typename T>
void foo2(T& t);

template<typename T>
void foo3(T&& t);

template<typename T>
void foo4(const T&& t);

template<typename T>
void foo5(T t);

template<typename T>
void foo6(const T t);

And how the following kinds of expressions/variables affect the deduced type:

int x{};
0;                      // literal (rvalue)
const int cx{};
int& rx = x;
const int& crx = x;
int&& rrx = 0;
const int&& crrx = 0;
const int& crx_temp = 0; // const lvalue reference to rvalue (extended lifetime)

πŸ’‘ PRIMER: Template Type Deduction Rules

C++ uses template argument deduction to determine what T should be based on the types of arguments passed. The deduction rules depend on the function parameter's form:

1. T& – Non-const Lvalue Reference

  • Only binds to non-const lvalues.
  • Deduction: T is the exact type of the argument, minus any reference.

2. const T& – Const Lvalue Reference

  • Binds to both const and non-const lvalues, and also rvalues.
  • Deduction: T is the type of the argument, even if it’s a temporary (rvalue).
  • const applies to the reference itself, not T.

3. T&& – Universal (Forwarding) Reference

  • When used in a template, it becomes a universal reference.
  • If the argument is an lvalue: T is deduced as an lvalue reference type (int&)
  • If the argument is an rvalue: T is deduced as int, so T&& becomes int&&

4. const T&& – Const Rvalue Reference

  • Binds only to rvalues.
  • T is deduced as the argument type without reference, then combined with const and &&.

5. T – By Value

  • Makes a copy.
  • Top-level const/reference is stripped from the argument when deducing T.

6. const T – By Value, Const Copy

  • Same as T, but copy is treated as const inside the function.

πŸ“‹ FULL TABLE WITH DEDUCED T

Variable foo1(const T&) foo2(T&) foo3(T&&) foo4(const T&&) foo5(T) foo6(const T)
int x{} T = int T = int T = int& ❌ error T = int T = int
0 T = int ❌ error T = int T = int T = int T = int
const int cx{} T = int ❌ error T = const int& ❌ error T = int T = int
int& rx = x T = int T = int T = int& ❌ error T = int T = int
const int& crx = x T = int ❌ error T = const int& ❌ error T = int T = int
int&& rrx = 0 T = int ❌ error T = int&& T = int T = int T = int
const int&& crrx = 0 T = int ❌ error T = const int&& T = int T = int T = int
const int& crx_temp = 0 T = int ❌ error T = const int& ❌ error T = int T = int

πŸ” EXPLANATIONS FOR EACH FUNCTION

foo1(const T& t)

  • Accepts any value (lvalue or rvalue).
  • Deduces T from the value type, discarding references and constness.
  • This is extremely flexible and commonly used.

foo2(T& t)

  • Accepts non-const lvalues only.
  • Temporaries (like 0) are rvalues and cannot bind to non-const lvalue references β†’ compilation error.
  • If the input is const, it also fails β€” because T& cannot bind to const int.

foo3(T&& t)

  • This is a universal reference (also called forwarding reference).

  • If the argument is:

    • an lvalue β†’ T is int&, so T&& becomes int& && β†’ collapses to int&.
    • an rvalue β†’ T is int, so T&& is int&&.

foo4(const T&& t)

  • Accepts rvalues only.
  • Cannot bind to lvalues.
  • Rarely used. Not useful for perfect forwarding.

foo5(T t)

  • Argument is passed by value.
  • Copies (or moves) the argument.
  • Constness and references are stripped: T is deduced as the underlying value type.

foo6(const T t)

  • Same deduction as foo5(T t).
  • The only difference is that within the function, the copy is const.

🧠 INSIGHT INTO REFERENCE COLLAPSING

In foo3, something magical happens:

If T is int&, then T&& becomes int& &&, which collapses to int&.

If T is int, then T&& remains int&&.

This is reference collapsing. The rules are:

T T&& Collapses to
int int&& int&&
int& int& && int&
int&& int&& && int&&

πŸ’₯ WHY foo2 CANNOT TAKE TEMPORARIES

C++ has a safety rule: you cannot bind non-const lvalue references to temporaries (rvalues), because the reference could outlive the temporary (which would be undefined behavior).

So:

foo2(0); // ERROR: can't bind non-const lvalue ref to rvalue

But foo1(const T&) can bind to a temporary, because it's a const reference.


πŸ”„ const lvalue reference to rvalue

When you write:

const int& crx_temp = 0;

You're using a feature called lifetime extension: the temporary 0 is extended to the lifetime of the reference. So this is a safe and legal reference.

Passing crx_temp to a template function behaves as if you're passing a const int&.


πŸ§ͺ Summary of Binding Rules

Argument kind T deduced as T& binds? const T& binds? T&& binds?
int x{} int βœ… βœ… βœ…
0 int ❌ βœ… βœ…
const int cx{} int ❌ βœ… βœ…
int& rx = x int βœ… βœ… βœ…
const int& crx int ❌ βœ… βœ…
int&& rrx = 0 int ❌ βœ… βœ…

🧰 When to Use Each Form

  • const T& (foo1): safest default for general-purpose, read-only input.
  • T& (foo2): when you need to mutate the caller’s variable.
  • T&& (foo3): for perfect forwarding.
  • const T&& (foo4): rare; used only in niche cases like move-only operations.
  • T (foo5): when you need a copy.
  • const T (foo6): copy and read-only inside function (but unnecessary if not mutated anyway).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment