Key Features in C++14/17

(This passage contains 0 words)

Table of Contents

    This passage lists some of the most basic features of C++14 and C++17 for future reference. Example programs can be found in Discovering C++/cpp-features.

    Part One: Features in C++14

    More Generic Type Deduction

    Lambda expressions were introduced in C++11. However, keyword auto was not allowed to be put into their argument lists. This feature was enabled in C++14.

    
    auto add_two_objects = [](auto a, auto b) { return a + b; };
                

    In C++11, such syntax is not allowed and the compiler will report an error: 'auto' not allowed in lambda parameter. This feature is useful for the convenience to replace the following template function:

    
    template <typename class_a, typename class_b, typename return_t>
    return_t corresponding_template(class_a a, class_b b) { return a + b; }
                

    In addition, type deduction for return value is also an extension in C++14. Therefore, using auto to deduce the return type of a function is also forbidden in C++11.

    Variable Template

    Given an initial condition, we can instantiate an object. When it comes to objects that are of different types but the same initial condition, variable templates can be quite useful. For instance, we can declare a class that requires an initial value.

    
    float initial_value = 3.1415926;
    template <typename var_t>
    struct container {
        var_t var;
        template <typename any_t>
        explicit container(any_t v): var(static_cast<var_t>(v)) {};
    };
                

    Then we can declare a template variable, and use the initial value we have set to initialize a series of objects of different types.

    
    template <typename type>
    container c = container<type>{initial_value};
                

    Object c here is a variable template. Like any template, it can be derived into objects of different types, but of the same initial value (3.1415926).

    
    container<int>     int_c    = c<int>;
    container<double>  double_c = c<double>; 
                

    However, it's worth noting that variable templates cannot be class members. To achieve better encapsulation within a class, we can use template functions.

    
    class private_value {
        static const int m_value = 2024;
    public:
        template <typename object_t>
        object_t operator ()() { return object_t{m_value}; }
    };
                

    Better "Constant Expressions"

    Keyword constexpr, which was introduced in C++11, allows compile-time computation. Such values are constant and available during compile time, and therefore can be used when compile-time constants are needed, such as identifying the length of an array.

    
    constexpr int len = 2;
    int my_array[len] = {1, 2};  // Not ok if len is only constant
                

    Such expressions can be used in functions as well. These functions are computed at compile time, and return values are instantly available.

    
    constexpr int cal_length(const int init_v) {
        return init_v + 1;
    }
                

    However, more complex computations, where loops, conditions and some other control flows are involved, are restricted in constant expressions in C++11. They are allowed in C++14.

    
    constexpr int cal_length(const int time, const int init_v) {
        int len = init_v;
        // Error: Statement not allowed in constexpr function (C++11)
        for (int i = 1; i < time; ++i)
            len *= i;
        return len;
    }
                

    Integer Sequence

    Defined in <utility>, integer sequence was introduced in C++14, and is used for representing compile-time sequence of integers. Let's first look into its implementation:

    
    /* Alias */
    template <size_t... _Ip>
    using index_sequence = integer_sequence<size_t, _Ip...>;
    
    /* Impl */
    template <class _Tp, _Tp... _Ip>
    struct integer_sequence {
        typedef _Tp value_type;
        static_assert( is_integral<_Tp>::value,
                      "std::integer_sequence can only be instantiated with an integral type" );
        static constexpr size_t size() noexcept { return sizeof...(_Ip); }
    };
                

    In this implementation, we can see that an integer sequence has a type alias value_type, which represents the type of integers. There's also an assertion which is used to limit this type to any integer types such as int, unsigned, long and more. Member method size() returns the number of integers in the sequence. Note that this is an important usage of sizeof, especially when it receives an argument package:

    
    template <typename... t>
    void get_size(t... n) {
        return sizeof...(n);        // Returns the number of elements in n
    }
                

    From the implementation of integer sequence, we can see that it doesn't store any value. Instead, it's able to compute the number of integers given as template arguments, i.e. _Ip, at runtime. Index sequence is one alias for integer sequence. It is one specialized version where the type of integers is size_t, which is the type of sizeof. To visit the integers that are supposed to be "stored" in the sequence, we can only visit the template arguments.

    An example on CPP Reference reveals the most basic usage of integer sequence, that is, just treat it as an integer sequence. Using fold expressions would be convenient, but it's not introduced until C++17. Here's how we can do it in C++14 to print specified elements in an array:

    
    template <typename T, std::size_t... Is>
    void print_array_elements(const T& arr, std::integer_sequence<std::size_t, Is...>) {
        using expander = int[];
        static_cast<void>(expander{0, (void(std::cout << arr[Is] << " "), 0)...});
    }
    
    int main() {
        constexpr int arr[5] = {1, 2, 3, 4, 5};
        print_array_elements(arr, std::integer_sequence<std::size_t, 1, 3, 0>{});
    }
                

    We'll discuss later in the next section about the use of fold expressions and this seemingly complicated trick presented in print_array_elements function. Now we just need to focus on two useful helper templates which generates an integer sequence of a given length N (Quoted from CPP Reference).

    
    template <class T, T N>
    using make_integer_sequence = std::integer_sequence<T, /* a sequence 0, 1, 2, ..., N-1 */>;
    
    template <std::size_t N>
    using make_index_sequence = std::make_integer_sequence<std::size_t, N>;
                

    Part Two: Features in C++17

    Structured Binding

    Unlike Python and many other languages, C++ does not natively allow decomposition of a complex structure such as a tuple or a list. In Python, it is ok if we want to retrieve elements through the following syntax.

    
    a, b = [1, 2]
    b, a = [a, b]   # Exchange a and b
                

    C++17 support similar syntax that is called structured binding. Now we can retrieve elements from containers that support structured binding just like Python.

    
    auto t = std::tuple<int, float>{1, 3.14};
    auto [e1, e2] = t;      // e1: int = 1, e2: float = 3.14
                

    To make our own container support structured binding, we have to make it behave like a tuple. This restriction is implemented in C++23, where concepts such as tuple-like and pair-like are supported. This will be discussed in future chapters. By now, we have to know that tuple-like containers are those where the following three methods are implemented: std::get, std::tuple_element and std::tuple_size. And of course, to complete implementation for these methods, we have to specify in namespace std. The following is an example. In this example, we're trying to write a list that is just like in Python, where we'll meet some of the features in C++17. See section Example.

    Fold Expressions

    As is already told before, fold expression is a C++17 extension, which is generally used to deal with argument packages. It offers much flexibility to create versatile template functions. Before C++17, fold expressions do not exist. So let's first look into how we deal with iteration loops with argument packages before and after C++17. The following function is a simplified example.

    Ellipsis in C

    The ellipsis symbol (...) is introduced in C and inherited by C++. It allows a function to accept a variadic number of arguments. Operations involving this symbol are methods provided by header <stdarg.h> (then <cstdarg> in C++). Check out CPP Reference for skills to work with stdarg ().

    From C++11 to C++14

    C-styled variadic argument list is complex and can easily lead to problems. For example, in some cases we have to manually handle the number of arguments and in most cases we have to face macros. Fortunately, from now we can temporarily forget previous C-styled variadic argument list. The following example combines two implementations before and after C++17.

    
    #if _LIBCPP_VERSION < 11
    #error "Not supported. Use cstdarg instead."
    #endif
    #include <iostream>
    template <typename char_t, char_t... chars>
    void print_chars() {
    #if _LIBCPP_VERSION < 17
        /* C++14 and before */
        static_cast<void>(int[]{0, (void(std::cout << chars), 0)...});
    #else
        /* C++17 and later */
        ((std::cout << chars), ...);
    #endif
    }
    int main() {
        print_chars<char, 'H', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd'>();
        // Output: Hello, world
    }                    
                

    Here we have a template parameter pack named chars. They're all values of type char_t, rather than types. std::cout << chars prints the value of chars, and returns an std::ostream& instance (usually for the next << statement). Therefore we have to convert it into void type to make the return value be ignored. The external {} forms an initializer list, which requires the types of arguments to be the same. The first 0 is of integer type, and the comma statement turns every (void(std::cout << chars), 0) into 0 by returning the last element, which is also of integer type. The ellipsis outside the comma expression iterates on each possible value of chars. Finally, we convert the integer pointer int[] into a void pointer. This is different from the void inside the initializer list which can be omitted, because it helps the compiler ignore unused pointer.

    So in the previous example, we seem to be working with a void-typed pointer. But actually we just make use of the initializer list to make the ellipsis symbol useful in iterating possible values of the template parameter pack. This is a common trick before C++17.

    C++17 and Later

    C++17 provide fold expressions to avoid such complex tricks as in C++14 and before. The expressions itself is wrapped in a pair of curves (), and consists of two parts. The first part is a statement that you want it to perform on each value in a template parameter pack, and the second part is an ellipsis symbol. In the previous example, we have that

    
    ((std::cout << chars), ...);
                

    This means we perform an output operation on each value of pack chars. The ellipsis is more merely a symbol that indicates the statement is a fold expression.

    Variant and Any

    Both introduced in C++17, variant and any serve serve similar purposes, the previous is for storing value of some predefined types, and the later for storing value of any type - as its name suggests. The following example shows the way to define instances of these two types.

    
    constexpr auto init_value = float{3.14159};
    constexpr auto variant_value = std::variant{init_value};
    auto any_value = std::any{init_value};  // or std::make_any(init_value);
                

    It's worth noting that any type does not support constant expression syntax, and requires the object to be copy-constructible. In normal cases, variant objects should always be bound with a value. It is hard, however, to put them into a state where they don't have values (while this is possible, it's just difficult and ill-formed). Any objects can hold no values.

    To extract values from these objects, we have to provide the corresponding types. This is important to both any and variant objects. These operations are simple and the following example shows how to perform them.

    
    auto retrieve_from_variant = std::get<float>variant_value;
    auto retrieve_from_any = std::any_cast<float>any_value;
                

    String View

    C++98 introduced the first (and widely censured) string container, i.e. std::string, and for many years developers have been accustomed to write their own string classes for better efficiency. In C++17, we have another string-like container: string view. String view is such an array of character-typed elements, where the first element is at index zero. The prototype fo this container is std::basic_string_view, which takes two template parameters: character type and character traits (have a default value). And this basic string view is specified into 5 types, among which the most widely used is std::string_view.

    
    template <typename char_t, typename traits = std::char_traits<char_t>>
    class std::basic_string_view;
    using std::string_view = std::basic_string_view<char>;
                

    The purpose of string view is similar to the original string container. Their difference mainly lies in the way they manage memory. The old string container, i.e. std::string, copies data from some initial value and owns the memory where copied data are located. It automatically allocates, resizes and deallocates the momery it owns. However, string view does not own a piece of memory where the data it binds with is located. String view is more like a pointer or an alias to the string.

    String view is a read-only view of the string. It's an ideal container when you only want to read from a string or only part of it (like the substring) without having to copy the entire string. Unlike normal string containers, it can be constructed at compile time (with constexpr). The following part displays several important functions of string views.

    
    /* Member functions */
    // Returns an iterator pointing at the first element of string
    constexpr const char_t* begin() const noexcept;
    // Returns an iterator pointing at the later one to the last element of string
    constexpr const char_t* end() const noexcept;
    // Accesses elements
    constexpr const char_t& operator[]( size_type pos ) const;
    constexpr const char_t& at( size_type pos ) const;
    // Returns a pointer to the first element
    constexpr const chat_t* data() const noexcept;
    // Returns the number of elements in the string; They are the same
    constexpr std::size_t size() const noexcept;
    constexpr std::size_t length() const noexcept;
    // Hash support; Use operator() to visit the hash results
    template <> struct hash<std::string_view>;
                

    If Constant Expression

    Introduced in C++17, if constant expressions (if constexpr) allow evaluation of a condition at compile-time. Compilers will compile all branches when it comes to normal condition flows. However, for if constexpr, only the branch that's corresponding to the true condition will be compiled, and other branches will be ignored to avoid compile-time errors.

    
    template <typename some_number>
    void judge_type(some_number n) {
        if constexpr (std::is_integral<some_number>::value) { std::cout << "Integer"; }
        else if constexpr (std::is_float_point<some_number>::value) { std::cout << "Float"; }
        else { std::cout << "Other type"; }
        std::cout << ": " << n << std::endl;
    }
    
    int main() {
        judge_type(3.14);  // Only compile the second branch
    }
                

    Raw Byte Storage

    Derived from unsigned char, C++17 introduces a new container that stores raw binary data. In addition, this container is overloaded with many widely used binary operators. For example, the following program instantiates a binary container and performs some basic operations.

    
    #include <cstddef>                        // Provide std::byte since C++17
    #include <cassert>                        // Provide macro assert
    auto binary_code = std::byte{0b10101010};
    binary_code <<= 1;                              // binary_code has value 0b01010100
    assert(std::to_int(binary_code) == 0b01010100); // Ok to compare integers
    assert(binary_code == std::byte{0b01010100}); // Ok to compare byte objects
    /* Not ok to compare an integer and a byte object:
       assert(binary_code == 0b01010100); */
                

    Reference

    1. CPP Reference (https://en.cppreference.com/).
    2. C++17 STL Cookbook by Jacek Galowicz, 2017.