16.4 — Passing and returning std::vector, and an introduction to move semantics

An object of type std::vector can be passed to a function just like any other object. That means if we pass a std::vector by value, an expensive copy will be made. Therefore, we typically pass std::vector by (const) reference to avoid such copies.

With a std::vector, the element type is part of the type information of the object. Therefore, when we use a std::vector as a function parameter, we have to explicitly specify the element type:

#include <iostream>
#include <vector>

void passByRef(const std::vector<int>& arr) // we must explicitly specify <int> here
{
    std::cout << arr[0] << '\n';
}

int main()
{
    std::vector primes{ 2, 3, 5, 7, 11 };
    passByRef(primes);

    return 0;
}

Passing std::vector of different element types

Because our passByRef() function expects a std::vector<int>, we are unable to pass vectors with different element types:

#include <iostream>
#include <vector>

void passByRef(const std::vector<int>& arr)
{
    std::cout << arr[0] << '\n';
}

int main()
{
    std::vector primes{ 2, 3, 5, 7, 11 };
    passByRef(primes);  // ok: this is a std::vector<int>

    std::vector dbl{ 1.1, 2.2, 3.3 };
    passByRef(dbl); // compile error: std::vector<double> is not convertible to std::vector<int>

    return 0;
}

In C++17 or newer, you might try to use CTAD to solve this problem:

#include <iostream>
#include <vector>

void passByRef(const std::vector& arr) // compile error: CTAD can't be used to infer function parameters
{
    std::cout << arr[0] << '\n';
}

int main()
{
    std::vector primes{ 2, 3, 5, 7, 11 }; // okay: use CTAD to infer std::vector<int>
    passByRef(primes);

    return 0;
}

Although CTAD will work to deduce an vector’s element type from initializers when it is defined, CTAD doesn’t (currently) work with function parameters.

We’ve seen this kind of problem before, where we have overloaded functions that only differ by the parameter type. This is a great place to make use of function templates! We can create a function template that parameterizes the element type, and then C++ will use that function template to instantiate functions with actual types.

Related content

We cover function templates in lesson 10.14 -- Function templates.

We can create a function template that uses the same template parameter declaration:

#include <iostream>
#include <vector>

template <typename T>
void passByRef(const std::vector<T>& arr)
{
    std::cout << arr[0] << '\n';
}

int main()
{
    std::vector primes{ 2, 3, 5, 7, 11 };
    passByRef(primes); // ok: compiler will instantiate passByRef(const std::vector<int>&)

    std::vector dbl{ 1.1, 2.2, 3.3 };
    passByRef(dbl);    // ok: compiler will instantiate passByRef(const std::vector<double>&)

    return 0;
}

In the above example, we’ve created a single function template named passByRef() that has a parameter of type const std::vector<T>&. T is defined in the template parameter declaration on the previous line: template <typename T. T is a standard type template parameter that allows the caller to specify the element type.

Therefore, when we call passByRef(primes) from main() (where primes is defined as a std::vector<int>), the compiler will instantiate and call void passByRef(const std::vector<int>& arr).

When we call passByRef(dbl) from main() (where dbl is defined as a std::vector<double>), the compiler will instantiate and call void passByRef(const std::vector<double>& arr).

Thus, we’ve created a single function template that can instantiate functions to handle std::array arguments of any element type and length!

Passing a std::vector using a generic template or abbreviated function template

We can also create a function template that will accept any type of object:

#include <iostream>
#include <vector>

template <typename T>
void passByRef(const T& arr) // will accept any type of object that has an overloaded operator[]
{
    std::cout << arr[0] << '\n';
}

int main()
{
    std::vector primes{ 2, 3, 5, 7, 11 };
    passByRef(primes); // ok: compiler will instantiate passByRef(const std::vector<int>&)

    std::vector dbl{ 1.1, 2.2, 3.3 };
    passByRef(dbl);    // ok: compiler will instantiate passByRef(const std::vector<double>&)

    return 0;
}

In C++20, we can use an abbreviated function template (via an auto parameter) to do the same thing:

#include <iostream>
#include <vector>

void passByRef(const auto& arr) // abbreviated function template
{
    std::cout << arr[0] << '\n';
}

int main()
{
    std::vector primes{ 2, 3, 5, 7, 11 };
    passByRef(primes); // ok: compiler will instantiate passByRef(const std::vector<int>&)

    std::vector dbl{ 1.1, 2.2, 3.3 };
    passByRef(dbl);    // ok: compiler will instantiate passByRef(const std::vector<double>&)

    return 0;
}

Both of these will accept an argument of any type that will compile. This can be desirable when writing functions that we might want to operate on more than just a std::vector. For example, the above functions will also work on a std::array, a std::string, or some other type we may not have even considered.

The potential downside of this method is that it may lead to bugs if the function is passed an object of a type that compiles but doesn’t make sense semantically.

Asserting on array length

Consider the following template function, which is similar to the one presented above:

#include <iostream>
#include <vector>

template <typename T>
void printElement3(const std::vector<T>& arr)
{
    std::cout << arr[3] << '\n';
}

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };
    printElement3(arr);

    return 0;
}

While printElement3(arr) works fine in this case, there’s a potential bug waiting for a unwary programmer in this program. See it?

The above program prints the value of the array element with index 3. This is fine as long as the array has a valid element with index 3. However, the compiler will happily let you pass in arrays where index 3 is out of bounds. For example:

#include <iostream>
#include <vector>

template <typename T>
void printElement3(const std::vector<T>& arr)
{
    std::cout << arr[3] << '\n';
}

int main()
{
    std::vector arr{ 9, 7 }; // a 2-element array (valid indexes 0 and 1)
    printElement3(arr);

    return 0;
}

This leads to undefined behavior.

One option here is to assert on arr.size(), which will catch such errors when run in a debug build configuration. Because std::vector::size() is a non-constexpr function, we can only do a runtime assert here.

Tip

A better option is to avoid using std::vectorin cases where you need to assert on array length. Using a type that supports constexpr arrays (e.g. std::array) is probably a better choice, as you can static_assert on the length of a constexpr array. We cover this in future lesson 17.3 -- Passing and returning std::array.

The best option is to avoid writing functions that rely on the user passing in a vector with a minimum length in the first place.

Returning a std::vector

When we need to pass a std::vector to a function, we pass it by (const) reference so that we do not make a copy.

Therefore, you will probably be surprised to find that it is okay to return a std::vector by value.

Say whaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaat?

Introduction to move semantics

Consider the following program:

#include <iostream>
#include <vector>

int main()
{
    std::vector arr1 { 1, 2, 3, 4, 5 }; // copies { 1, 2, 3, 4, 5 } into arr1
    std::vector arr2 { arr1 };          // copies arr1 into arr2

    arr1[0] = 6; // We can continue to use arr1
    arr2[0] = 7; // and we can continue to use arr2

    std::cout << arr1[0] << arr2[0] << '\n';

    return 0;
}

When arr2 is initialized with arr1, the copy constructor of std::vector is called, which copies arr1 into arr2.

Making a copy is the only reasonable thing to do in this case, as we need both arr1 and arr2 to live on independently. This example ends up making two copies, one for each initialization.

When initializing or assigning a value to an object copies the value (from another object), we say the object is using copy semantics.

Now consider this related example:

#include <iostream>
#include <vector>

std::vector<int> generate() // return by value
{
    std::vector arr1 { 1, 2, 3, 4, 5 }; // copies { 1, 2, 3, 4, 5 } into arr1
    return arr1;
}

int main()
{
    std::vector arr2 { generate() }; // the return value of generate() dies at the end of the expression

    // There is no way to use the return value of generate() here
    arr2[0] = 7; // we only have access to arr2

    std::cout << arr2[0] << '\n';

    return 0;
}

When arr2 is initialized this time, it is being initialized using a temporary object returned from function generate(). Unlike the prior case, where the initializer was an lvalue that could be used in future statements, in this case, the temporary object is an rvalue will be destroyed at the end of the initialization expression. The temporary object can’t be used beyond that point. Because the temporary (and its data) will be destroyed at the end of the expression, we need some way to get the data out of the temporary and into arr2.

The usual thing to do here is the same as in the previous example: use copy semantics and make a potentially expensive copy. That way arr2 gets its own copy of the data that can be used even after the temporary (and its data) is destroyed.

However, what makes this case different than the previous example is that the temporary is going to be destroyed anyway. After initialization is complete, the temporary doesn’t need its data any more (which is why we can destroy it). We don’t need two sets of data to exist simultaneously. In such cases, making a potentially expensive copy and then destroying the original data is suboptimal.

Instead, what if there was a way for arr2 to “steal” the temporary’s data somehow? arr2 would then be the new owner of the elements we care about, and no copy would need to be made. When the temporary was then destroyed at the end of the expression, it would no longer have any data to destroy, so we wouldn’t have to pay that cost either.

This is the essence of move semantics: instead of making a copy and then destroying the original, we just move the data from the object that is about to be destroyed to the one that lives on. The cost of such a move is typically trivial (usually just two or three pointer assignments, which is way faster than copying an array of data!).

When initializing or assigning a value to an object moves the value (from another object), we say the object is using move semantics.

Move semantics will be used instead of copy semantics when all of the following are true:

  • The type supports move semantics.
  • We are initializing or assigning a value to an object using an rvalue object of the same type.

Here’s the sad news: not that many types support move semantics. However, std::vector and std::string both do!

We’ll dig into how move semantics works in more detail in chapter 22. For now, it’s enough to know what move semantics is, and which types are move-capable.

We can return move-capable types like std::vector by value

Because return by value returns an rvalue, if the returned type supports move semantics, then the returned value can be moved instead of copied into the destination object!

This makes return by value extremely inexpensive for these types!

Key insight

We can return move-capable types (like std::vector and std::string) by value. Such types will inexpensively move their values instead of making an expensive copy.

Quiz time

Question #1

Write a function that takes two parameters: a std::vector and an index. If the index is out of bounds, print an error. If the index is in bounds, print the value of the element.

The following sample program should compile:

#include <iostream>
#include <vector>

// Write your printElement function here

int main()
{
    std::vector v1 { 0, 1, 2, 3, 4 };
    printElement(v1, 2);
    printElement(v1, 5);

    std::vector v2 { 1.1, 2.2, 3.3 };
    printElement(v2, 0);
    printElement(v2, -1);

    return 0;
}

and produce the following result:

The element has value 2
Invalid index
The element has value 1.1
Invalid index

Show Solution

guest
Your email address will not be displayed
Find a mistake? Leave a comment above!
Correction-related comments will be deleted after processing to help reduce clutter. Thanks for helping to make the site better for everyone!
Avatars from https://gravatar.com/ are connected to your provided email address.
Notify me about replies:  
1 Comment
Newest
Oldest Most Voted
Inline Feedbacks
View all comments