dokee's site

Back

Modern C++ Basics - Template Basics and Move SemanticsBlur image

Compile-time Evaluation#

Compile-time variable#

  • constexpr force the variable to be determined in compile time.
  • constinit ensures the initial value is determined in compile time, while make it changeable afterwards.
    • You can only use constinit for global / static / thread-local variables.
    • It can help to solve static initialization order fiasco.

Compile-time function#

  • constexpr functions are allowed to not get the value at the compile time.
  • If you want to force the function to be evaluated at compile time, you need consteval.
  • These two specifiers can also be added in lambda.
    • Notice that if all operations in lambda are possible to be in constexpr, constexpr is implied (so explicit specification can be omitted).

Compile-time Branch Selection#

Function overload resolution and specialization#

Template specialization#

template <typename T>
void foo(T val) {
    std::println("val: {}", val);
}

template <> // specialization
void foo(int val) {
    std::println("int val: {}", val);
}

template void foo(float val); // instantiation
cpp
  • Don’t mistake template specialization from explicit template instantiation.
  • A specialization must be declared before it’s used, otherwise the behavior is implementation-defined.
  • A full specialization isn’t a template anymore; thus you cannot define it in header file.
    • You can either use inline, or only write the specialization prototype.
    • You can add new specifiers (e.g. inline, constexpr)

Overload resolution#

  • Overload resolution just tries to find “the most precise one” determined by parameters. The order is:
    1. Perfect match or match with minimal adjustments (i.e. decay, add cvqualifier).
    2. Match with promotion, e.g. short -> int, float -> double.
    3. Match with standard conversions (pre-defined ones), e.g. int -> short.
    4. Match with user-defined conversions.
  • If there are still more than one candidates, more rules will apply:
    • More “specialized” ones are preferred, including considering value category;
    • Non-template ones are preferred than template ones;
    • For pointers, conversion order is: Derived-to-base > void* > bool.
    • For std::initializer_list, when using universal initialization, it’s preferred over other ones.
      • And that’s why std::vector(5, 1) is different from std::vector{5, 1}.
    • Functors are preferred over surrogate functions (i.e. need conversion to become callable functor).
template<typename T> void Func(T); // Func(nullptr) matches this -> Func(nullptr_t)
template<typename T> void Func(T*);
template<> void Func(int*);
cpp
  • Formally, we say template A is more specialized than B if:
    • Hypothesize that there exist concrete types U1, U2, … to substitute all template parameters in A, if it couldn’t be deduced by B, then we say A isn’t more specialized by B.
  • Maybe neither template is more specialized than the other, which causes ambiguous call.
  • Judged when calling.
template<typename T> void Func(T); // #1
template<typename T> void Func(T*); // #2: more specialized

// #2 Func(U1*) -> Can be derived from #1? true --> #2 is more specialized
// #2 Func(U1) -> Can be derived from #2? false --> #1 is not more specialized
cpp

Class template specialization#

  • Specialized class is a separate class, which can have completely different data member and member functions.
  • Partial specialization
    • e.g. template<typename T> class Foo(T*) {};
    • Not allowed to have default template parameter.
      • Specialization only determines “whether a type matches it”; it doesn’t determine “what a type is”. (can only determined by the primary template)
    • Allowed to be defined inside the class.

Selection in code block#

template <typename T>
auto get_value(T t) {
    if constexpr (std::is_pointer_v<T>)
        return *t; // deduces return type to int for T = int*
    else
        return t;  // deduces return type to int for T = int
}
cpp
constexpr bool is_constant_evaluated() noexcept {
    if consteval {
        return true;
    } else {
        return false;
    }
}
cpp

Tricky Details#

Name lookup#

  • Names could be divided into two parts:
    • Dependent / Non-dependent name: if a name depends on template parameter, then it’s dependent name.
    • Qualified / Non-qualified name: if a name is specified by ::, ., ->, then it’s qualified. A fully qualified name is like ::a.b.
  • two-phase lookup:
    • Non-dependent names are looked up when template is defined.
    • Dependent names are looked up when template is instantiated.
      • Reason: template may be specialized afterwards.

this->#

template <typename T>
struct A {
    int a;
};

template <typename T>
struct B : public A<T> {
    int func() {
        return this->a; // return a; -> error
    };
};
cpp

typename#

  • you can use typename when:
    • The type is a qualified name;
    • It’s not after keywords class/struct/union/enum;
    • It’s not Base class appears at inheritance specification and ctor.
  • And you must use typename when:
    • Match all rules above;
    • The type is a dependent name;
    • It’s not the current instantiation.
  • Since C++20, many rules are relaxed.

template#

template <typename T>
struct S {
    template <typename U>
    void foo() {}
};

template <typename T>
void bar() {
    S<T> s;
    // s.foo<T>(); // error: < parsed as less than operator
    s.template foo<T>(); // OK
}
cpp

Concept#

require clause and concept#

requires clause#

  • concept: declares a named type requirement.
  • It is OK to put it after the function header.
template <typename T>
    requires(!std::is_pointer_v<T>) && (sizeof(T) >= 8)
T max(const T& a, const T& b) {
    // ...
}
// same as
template <typename T>
concept NotPtr = !std::is_pointer_v<T> && sizeof(T) >= 8;

template <typename T>
    requires NotPtr<T>
T max(const T& a, const T& b) {
    // ...
}
cpp

requires expression#

  • Simple requirement
  • Type requirement: template T::value_type;
  • Compound requirement: {...} noexcept -> ...
    • The result type will be fed as the first type parameter directly.
  • Nested requirement: requires ...;
template <typename T>
concept ComprehensiveContainer = requires(T container, typename T::value_type elem) {
    container.push_back(elem);

    typename T::value_type;
    typename T::iterator;

    { container.size() } -> std::convertible_to<std::size_t>;
    { container.clear() } noexcept;

    requires requires {
        requires std::default_initializable<typename T::value_type>;
        requires std::equality_comparable<typename T::iterator>;
    };
};
cpp
template <typename T, typename U>
void insert(T& cont, const std::ranges::range_value_t<T>& val) {
    cont.insert(val);
}

template <typename T>
void insert(T& cont, const std::ranges::range_value_t<T>& val) requires
    requires { cont.push_back(val); } {
    cont.push_back(val);
}
cpp
  • For any type deduction & template parameter, you can add a single constraint by abbr.
  • Concept can be used as boolean expression.
template<typename T>
concept Hashable = requires(T a)
{
    { std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
};

template<Hashable T>
void f(T) {}
// same as
template<typename T>
    requires Hashable<T>
void f(T) {}
// same as
template<typename T>
void f(T) requires Hashable<T> {}
// same as
void f(Hashable auto /* parameter-name */) {}
cpp
  • Template class member functions can be constrained. If the constraints are not met, no compilation error will occur, and the member function will simply be removed.
template <std::integral T>
struct Foo {
    auto test() -> bool requires std::same_as<T, int> {
        return true;
    }
};

Foo<long> foo; // OK
foo.Test();    // error
cpp

Concept subsumption#

  • We can use concept to do specialization, but specialization has matching order.
  • Compilers don’t do full logical judgement; instead, it only considers equivalence by concept name.
    • Non-concepts (including !Concept) will be always considered not related.
template <typename T>
class Foo;

template <typename T>
requires std::is_pointer_v<T> && (sizeof(T) >= 8)
class Foo<T> {};

template <typename T>
requires std::is_pointer_v<T> && (sizeof(T) >= 4)
class Foo<T> {};

template class Foo<int*>; // error, ambiguous
cpp

Universal Reference and Perfect Forwarding#

Universal Reference#

template <typename T>
void func(T&& t) {} // universal reference
// lvalue -> T: Type&
// const lvalue -> T: const Type&
// rvalue -> T: Type&&
cpp
  • Requirements:
    • T&&
    • The template type is provided by the function directly.
    • The type is the template parameter itself.
template <typename T>
void func(const T&& t) {} // not universal reference

template <>
void func(std::string&& str) {} // not universal reference

template <typename T>
void func(typename T::value_type&& t) {} // not universal reference

template <typename T>
class Foo {
    void func(T&&); // not universal reference
};
cpp
  • A special form of universal reference is auto&&.

Overload resolution on references#

  • Universal reference is always second-best choice.
  • Fallback will be disabled if universal reference is used! i.e. non-const lvalue and const rvalue cannot fallback on const& in copy ctor.
  • Solution: use concept!
  • Anyway, pay special attention to universal reference ctor!
    • For any function with universal reference parameter, it’s usually not a good idea to overload.
    • That will disable many implicit conversions since universal reference is an exact match.

Perfect Forwarding#

  • std::forward<T>
    • <T> is necessary.
    • Universal reference is always a reference; it cannot be a value type.
void func(auto&& arg) {
    func_(std::forward<decltype(arg)>(arg));
}
cpp

Reference Collapsing#

  • Reference Collapsing Rule:
    • If all references are rvalue reference, the collapsed reference is rvalue reference.
    • If there is at least one lvalue reference, the collapsed reference is lvalue reference.
template <typename T>
decltype(auto) std::move(T&& obj) {
    return static_cast<std::remove_reference_t<T>&&>(obj);
}

template <typename T>
decltype(auto) std::forward(std::remove_reference_t<T>& arg) {
    return static_cast<T&&>(arg);
}
cpp
  • Pay attention to deduction conflicts.
    • Solution: use std::remove_reference_t<T> or two template type parameters.
template <typename T>
void insert(std::vector<T>& vec, T&& elem) {
    vec.push_back(std::forward<T>(elem));
}

int main() {
    std::vector<std::string> vec; // T: std::string
    std::string str { "test" };   // T: std::string&
    // insert(vec, str); // error
}
cpp
Modern C++ Basics - Template Basics and Move Semantics
https://astro-pure.js.org/blog/c/modern-c-basics/template-basics-and-move-semantics
Author dokee
Published at March 14, 2025
Comment seems to stuck. Try to refresh?✨