Function Templates in C++: Generic Programming and Type Deduction Explained

Function Templates in C++

Function Templates in C++

Early on, I wrote three nearly identical functions — maxInt, maxDouble, and maxString — because I didn’t yet know C++ had a way to write the logic once and let the compiler handle the rest. That’s the entire motivation behind function templates: write the algorithm a single time, and let the type system generate the right version for whatever types show up at the call site. This is the foundation of generic programming in C++, and it’s what powers nearly all of the Standard Template Library (STL) — every algorithm in <algorithm>, every container operation, and most of <numeric>.

In this article, I’ll go through function templates in depth: syntax, how template argument deduction actually works (including the parts that trip people up, like reference collapsing and forwarding references), template specialization and overloading, constraints with concepts (C++20), and a good number of complete, compilable examples with their actual output.

What Is a Function Template?

A function template is a blueprint from which the compiler generates concrete functions, one per set of types actually used. The syntax:

template<typename T>
T maximum(T a, T b) {
    return (a > b) ? a : b;
}

Calling maximum(3, 7) causes the compiler to generate a version of maximum with T = int. Calling maximum(3.5, 2.1) generates another, entirely separate version with T = double. This process is called template instantiation, and it happens at compile time — there is no runtime dispatch or type-checking overhead involved.

#include <iostream>

template<typename T>
T maximum(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    std::cout << maximum(3, 7) << "\n";
    std::cout << maximum(3.5, 2.1) << "\n";
    std::cout << maximum(std::string("apple"), std::string("banana")) << "\n";
    return 0;
}

Output:

7
3.5
banana

Three completely different types, one function definition.

Template Argument Deduction

This is the mechanism that lets you write maximum(3, 7) instead of the more verbose maximum<int>(3, 7). The compiler looks at the types of the arguments you actually passed and works backward to figure out T.

Basic Deduction Rules

template<typename T>
void show(T value) {
    std::cout << value << "\n";
}

show(10);       // T deduced as int
show(3.14);     // T deduced as double
show('c');      // T deduced as char

Deduction with pointers and arrays:

template<typename T>
void showPointer(T* ptr) {
    std::cout << *ptr << "\n";
}

int x = 42;
showPointer(&x); // T deduced as int

Deduction with References

This is where a lot of people (myself included, initially) get tripped up. Consider three closely related parameter forms:

template<typename T> void byValue(T param);
template<typename T> void byLRef(T& param);
template<typename T> void byConstLRef(const T& param);
template<typename T> void byUniversalRef(T&& param);
#include <iostream>
#include <type_traits>

template<typename T>
void identify(T&& param) {
    if (std::is_lvalue_reference<T>::value)
        std::cout << "Bound to an lvalue\n";
    else
        std::cout << "Bound to an rvalue\n";
}

int main() {
    int x = 5;
    identify(x);           // lvalue -> T deduced as int&
    identify(10);           // rvalue -> T deduced as int
    identify(std::move(x)); // rvalue (moved) -> T deduced as int
    return 0;
}

Output:

Bound to an lvalue
Bound to an rvalue
Bound to an rvalue

Reference Collapsing Rules

When T&& is used with a deduced T, and T itself is deduced as a reference type, the compiler applies these collapsing rules:

Written formCollapses to
T& &T&
T& &&T&
T&& &T&
T&& &&T&&

In short: an lvalue reference “wins” unless both sides are rvalue references. This is precisely what makes std::forward<T>(param) work correctly inside a forwarding-reference function — it uses these exact rules to restore the original value category of the argument.

#include <iostream>
#include <utility>

void process(int& x)  { std::cout << "lvalue overload: " << x << "\n"; }
void process(int&& x) { std::cout << "rvalue overload: " << x << "\n"; }

template<typename T>
void wrapper(T&& arg) {
    process(std::forward<T>(arg)); // perfectly forwards value category
}

int main() {
    int a = 10;
    wrapper(a);          // calls process(int&)
    wrapper(20);         // calls process(int&&)
    wrapper(std::move(a)); // calls process(int&&)
    return 0;
}

Output:

lvalue overload: 10
rvalue overload: 20
rvalue overload: 10

Where Deduction Fails

Deduction cannot happen in a non-deduced context. The classic case is when T only appears as a return type:

template<typename T>
T create() {
    return T();
}

// create();          // ERROR: can't deduce T from nothing
int x = create<int>(); // OK: explicit template argument

Another common failure is implicit conversions being required to match a parameter:

template<typename T>
T addOne(T value) { return value + 1; }

// addOne(5, 3.0); // signature mismatch, this example is just wrong on arity
// A more real case: mismatched deduced types across params
template<typename T>
T combine(T a, T b) { return a + b; }

// combine(5, 3.5); // ERROR: T deduced as both int and double - ambiguous
#include <iostream>

template<typename T>
T combine(T a, T b) { return a + b; }

int main() {
    std::cout << combine(5, 3) << "\n";       // OK: both int
    std::cout << combine<double>(5, 3.5) << "\n"; // OK: explicit T = double
    return 0;
}

Output:

8
8.5

Multiple Template Parameters

Function templates aren’t limited to a single type parameter:

#include <iostream>

template<typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
    return a + b;
}

int main() {
    std::cout << add(5, 3.2) << "\n";      // int + double
    std::cout << add(2.5f, 10) << "\n";    // float + int
    return 0;
}

Output:

8.2
12.5

Note the trailing return type (-> decltype(a + b)) — necessary in older C++ standards because a and b aren’t in scope until after the parameter list. Since C++14, you can often simplify this with auto return type deduction directly:

template<typename T, typename U>
auto add(T a, U b) {
    return a + b;
}

Non-Type Template Parameters

Templates aren’t restricted to types — you can parameterize on compile-time constant values too, which is extremely useful for fixed-size arrays and compile-time computation.

#include <iostream>

template<typename T, int N>
T arraySum(T (&arr)[N]) {
    T sum = T();
    for (int i = 0; i < N; ++i) sum += arr[i];
    return sum;
}

int main() {
    int nums[] = {1, 2, 3, 4, 5};
    double decimals[] = {1.1, 2.2, 3.3};

    std::cout << arraySum(nums) << "\n";
    std::cout << arraySum(decimals) << "\n";
    return 0;
}

Output:

15
6.6

Here N is deduced automatically from the array size — no need to pass it explicitly.

Function Template Overloading and Specialization

You can overload function templates the same way you overload regular functions, and the compiler picks the best match using overload resolution rules (non-template exact matches are generally preferred over template instantiations, all else equal).

#include <iostream>

template<typename T>
void describe(T value) {
    std::cout << "Generic: " << value << "\n";
}

template<typename T>
void describe(T* value) {
    std::cout << "Pointer to: " << *value << "\n";
}

void describe(int value) {
    std::cout << "Non-template int overload: " << value << "\n";
}

int main() {
    int x = 10;
    describe(x);    // exact non-template match wins
    describe(&x);   // pointer overload
    describe(3.14); // generic template
    return 0;
}

Output:

Non-template int overload: 10
Pointer to: 10
Generic: 3.14

Full specialization lets you provide a distinct implementation for one particular type, while keeping the generic template for everything else:

#include <iostream>
#include <cstring>

template<typename T>
bool isEqual(T a, T b) {
    return a == b;
}

// Full specialization for C-strings, since == would compare pointers, not content
template<>
bool isEqual<const char*>(const char* a, const char* b) {
    return std::strcmp(a, b) == 0;
}

int main() {
    std::cout << std::boolalpha;
    std::cout << isEqual(5, 5) << "\n";
    std::cout << isEqual("hello", "hello") << "\n"; // uses the specialization
    return 0;
}

Output:

true
true

Without the specialization, comparing two const char* values with == would compare pointer addresses, not the string contents — a classic bug for beginners.

Constraining Templates: SFINAE and Concepts

Before C++20, constraining what types a template would accept required SFINAE (Substitution Failure Is Not An Error), typically via std::enable_if:

#include <iostream>
#include <type_traits>

template<typename T, typename std::enable_if<std::is_integral<T>::value, int>::type = 0>
T doubleValue(T value) {
    return value * 2;
}

int main() {
    std::cout << doubleValue(5) << "\n";      // OK, int is integral
    // doubleValue(5.5); // ERROR: substitution fails, no matching overload
    return 0;
}

Output:

10

C++20 concepts make this dramatically more readable:

#include <iostream>
#include <concepts>

template<std::integral T>
T doubleValue(T value) {
    return value * 2;
}

int main() {
    std::cout << doubleValue(5) << "\n";
    // doubleValue(5.5); // ERROR: constraint not satisfied, clear message
    return 0;
}

Output:

10

The error messages produced when a concept constraint fails are far clearer than the historically cryptic SFINAE substitution failures, which is one of the most practically useful upgrades in modern C++ template code.

Internal Working and Compiler Process

  1. Two-phase compilation. Templates are checked twice: once at definition (checking syntax and non-dependent names), and once at instantiation (checking dependent names and types against the actual template arguments). This is why you can write a + b inside a template without knowing yet whether + is even defined for the eventual type — the check for that happens at instantiation time.
  2. Instantiation on demand. The compiler only generates code for a specific T when that specific instantiation is actually used somewhere in the translation unit. This is called implicit instantiation. You can force generation ahead of time with explicit instantiation:
template int maximum<int>(int, int);
  1. One Definition Rule (ODR) and templates. Because template definitions typically live in headers, multiple translation units can each implicitly instantiate the same specialization. The linker is expected to merge identical instantiations (via weak/COMDAT symbols) rather than erroring — this is standard behavior in GCC and Clang.
  2. Deduction happens before overload resolution. For each candidate function template, the compiler tries to deduce template arguments from the call’s actual arguments. If deduction succeeds for multiple candidates, ordinary overload resolution rules (best match, then partial ordering of templates) decide the winner.
  3. No runtime type information involved. Once instantiated, a function template is just an ordinary function — there is no dynamic_cast-style runtime type checking hidden inside it, and no boxing/unboxing overhead like in some other languages’ generics.

Best Practices

Performance Optimization

Function templates carry zero runtime overhead relative to hand-written equivalents, because instantiation happens entirely at compile time and produces ordinary machine code per type. The actual performance considerations are:

#include <chrono>
#include <iostream>

template<typename T>
inline T fastAdd(T a, T b) { return a + b; }

int main() {
    auto start = std::chrono::high_resolution_clock::now();
    long long sum = 0;
    for (int i = 0; i < 10'000'000; ++i) {
        sum = fastAdd(sum, static_cast<long long>(i));
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::cout << "Sum: " << sum << "\n";
    std::cout << "Time: "
              << std::chrono::duration<double, std::milli>(end - start).count()
              << " ms\n";
    return 0;
}

This compiles down to a tight loop with no function-call overhead at all in an optimized build, since the compiler inlines fastAdd directly.

Debugging Common Mistakes

Mistake 1: Type deduction mismatch across parameters

template<typename T>
T maxVal(T a, T b) { return a > b ? a : b; }

// maxVal(5, 5.5); // ERROR: T can't be both int and double

Fix: use two separate template parameters, or explicitly specify T.

Mistake 2: Forgetting typename for dependent types

template<typename Container>
void printFirst(Container& c) {
    typename Container::value_type first = *c.begin(); // 'typename' required here
    std::cout << first << "\n";
}

Omitting typename before a dependent type name is a very common compile error for beginners moving into generic container code.

Mistake 3: Overload resolution surprises with forwarding references

template<typename T>
void log(T&& value) { /* ... */ }

void log(const std::string& value) { /* ... */ }

A forwarding-reference template overload can unexpectedly outcompete a seemingly more specific non-template overload for certain argument types, because forwarding references are a very greedy match. This is a well-known pitfall — be cautious mixing T&& templates with overloaded non-template functions of similar signature.

Mistake 4: Assuming templates behave like runtime generics. Unlike Java generics (type erasure) or similar systems, C++ templates generate a distinct type/function per instantiation. Code that relies on runtime polymorphism should generally use virtual functions or std::variant/std::any, not templates alone.

Real-World Applications

Interview Questions

  1. What is template argument deduction, and when does it fail?
  2. Explain reference collapsing rules and how they enable perfect forwarding.
  3. What’s the difference between T&, const T&, and T&& as function template parameters?
  4. Why is typename sometimes required before a type name inside a template?
  5. What is SFINAE, and how do concepts (C++20) improve on it?
  6. How does the compiler decide between a template and a non-template overload with the same effective signature?
  7. What’s the difference between implicit and explicit template instantiation?
  8. Why don’t function templates introduce runtime overhead?

FAQs

Q: Can function templates be virtual? No. Virtual functions are resolved via a vtable fixed at compile time per class, but templates need to generate a new function per type — the two mechanisms are fundamentally incompatible.

Q: Do function templates increase binary size? Potentially, yes — each distinct type instantiation produces its own generated code, though identical instantiations across translation units are typically merged by the linker.

Q: What’s the difference between a function template and a template function? “Function template” is the generic blueprint (template<typename T> T f(T)); “template function” usually (informally) refers to one specific instantiation of it, like f<int>.

Q: Can I explicitly specify only some template arguments and let the rest be deduced? Yes, as long as the explicitly-specified ones come first in the parameter list, e.g. add<int>(3, 4.0) fixes the first parameter’s type and lets the rest deduce.

Q: Are lambdas function templates? A generic lambda (using auto parameters, C++14+) is compiled essentially as a class with a templated operator(), so functionally it behaves very similarly to a function template.

Troubleshooting Tips

Summary and Key Takeaways

Function templates are the foundational tool for generic programming in C++, letting you write an algorithm once and have the compiler generate correctly-typed, zero-overhead versions for every type you actually use. The trickiest but most valuable part to master is template argument deduction — especially the reference collapsing rules that make forwarding references and std::forward work correctly. Combined with concepts in C++20, function templates now offer both the performance of compile-time specialization and error messages that are actually readable, making generic C++ code more approachable than it’s ever been.

References

Exit mobile version