Skip to main content
Programming2026-06-03

Coding Notes - June 3

A summary of C++ features I found particularly interesting while working on my vector library.

std::move in Object Instantiation

A powerful and optimal way for instantiating an object with non-trivial data:

Vector(std::vector<T>&& rray)
	: vec(std::move(rray)), ...
  • std::move transfers ownership of the data to the object attribute. This saves us the cost of copying the value.
  • Note: For this work, vectors being passed in the constructor must be temporary. Rvalues to be more specific. && is known as the rvalue reference, as opposed to & which is the more common lvalue reference. Rvalues references signal the compiler that the data is temporary, so moving its resources to a different place is alright.

Here is an example:

Vector<int> myVector(std::vector<int>{ 1,0,0 });
// as opposed to:
std::vector<int> v1 = {1, 0, 0};
Vector<int> myVector(v1); // THIS WILL NOT WORK!
  • The first line works because the data is clearly anonymous, i.e. it has no label or variable tied to it, making it an rvalue.
  • The reason passing an initialized vector won't work is that move semantics work with rvalues or temporary values, not lvalues or permanent ones.
  • One can imagine moving v1's data to the Vector object only to mistakenly try later to access v1 when the data it is supposed to store isn't there anymore!

Null Value of a Template Datatype

While working with templates, one will certainly come across the need to initialize data of the same type with a zero value. The thing is, this zero value differs in many datatypes. For int, it is actually just 0. But for float and double, we use 0.0f and 0.0 respectively.

In order to maintain cohesion in our code and avoid initializing a known zero value for an unknown template type, we use T{}, or whatever else one is using as the template variable name. This default-initializes the type.


Iterators for Object

I was working on a Vector class which used std::vector to store the actual vector (we'll call that atrribute vec) in an attribute. I found myself constantly writing Vector.vec.begin() and Vector.vec.end() in order to traverse the vector data. If operator overloading allowed random access to my std::vector inside the object, surely there must be a way to also quickly access its iterator.

The solution is not as elegant as I thought it would be, but it is thankfully very straightforward:

using iterator = std::vector<T>::iterator;
using const_iterator = std::vector<T>::const_iterator;

iterator begin() { return vec.begin(); }
iterator end() { return vec.end(); }

const_iterator begin() const { return vec.begin(); }
const_iterator end() const { return vec.end(); }
  • Firstly, I set up type aliases for iterator (read and update) and const_iterator (read only).
  • Then it is as simple as defining two class methods that return the iterators of the underlying std::vector.
  • Note: Similar to other operator overloads, we establish two versions of the same function, one which allows us to read and update values using the iterators and the other that only allows reading.

Q: How does the program choose which one to use?


std::reduce

// std::reduce defined in <numeric>
static float magnitude(const std::vector<T>& arr) {
		auto square_sum = std::reduce(std::execution::par_unseq,
			arr.begin(),
			arr.end(),
			T{ 0 },
			[](T total, T num) { return total + num * num; }
		);
		return static_cast<float>(std::sqrt(square_sum));
	}
  • std::reduce is an alternative to std::accumulate for "reducing" the contents of a container to a single value. The key difference between is the sequence of operations. While std::accumulate guarantees left-to-right aka sequential processing, std::reduce can process elements out of order in order to take advantage of hardware optimisations like vectorization and multi-threaded parallel operations.
  • Naturally, the arbitrary order necessitates operations that are both associative and commutative, which in this case is addition.
  • std::reduce takes a parallelization policy (which basically defines the kind of parallel configuration to use) whose options are defined in <execution>. In this case, std::execution::par_unseq refers to the Parallel Unsequenced Policy, which is the fastest execution policy (in general).
  • As for the arguments, we state the execution policy, container iterators, starting value, and the operation lambda or function. This lambda function must take an accumulator and element as arguments.

std::transform

Best way to perform element-wise operations with two containers.

// std::function defined in <functional>
// std::tranform defined in <algorithm>

// This is a template class method!
Vector<T> element_wise(const Vector& other, std::function<T(T, T)> func) const {
	if (!dims_match(other)) 
		throw std::invalid_argument("Vector dimensions must match.");
	
	Vector<T> result(this->dim);
	std::transform(vec.begin(), vec.end(), 
		other.begin(), result.begin(), 
		func
	);
	return result;
}
  • std::transform takes the iterators for the first container, start iterator of the second, start of the target container, and the operation function.
  • Note that std::transform blindly assumes that the second container has at least as many elements as the first one. If that is not the case, it leads to undefined behaviour.

Used for element-wise addition and subtraction of two vectors:

Vector<T> operator+(const Vector& other) const {
	return element_wise(other, std::plus<T>());
}

Vector<T> operator-(const Vector& other) const {
	return element_wise(other, std::minus<T>());
}

std::inner_product for Dot Product

Using the in-built function is best for calculating dot products:

// std::inner_product defined in <numeric>
T operator*(const Vector& other) const {
	if (!dims_match(other)) 
		throw std::invalid_argument("Vector dimensions must match.");
		
	return std::inner_product(vec.begin(), vec.end(), other.begin(), 0);
}
  • All it needs are the start and end iterators of the first container, start of the second, and the initial value.

Variadic Templates

Writing a function that takes a variable number of arguments requires using variadic patterns. In the case where the exact datatype isn't known in advanced, using variadic templates are the safest way, as they ensure type safety and compile-time optimisation.

template <typename... Args>
static std::vector<Vector<float>> gram_schmidt(Args&&... args) 
{
	std::vector<Vector<T>> input_vectors{ std::forward<Args>(args)... };
	// code...
}
  • template <typename... Args> declares the template parameter pack. The ellipsis notation makes Args a placeholder for a variable amount of datatypes.

  • Args&&... args declares the function parameter pack.

    • In this case, && is not used as an rvalue reference which was discussed in a previous note. Here, they indicate a universal reference, which is has the type T&& for some deduced type T. In other words, it is a universal reference if they is type deduction involved, like there is in our function. This makes Args a deduced template parameter.
    • The ... after && makes args the parameter pack.
  • In order to unpack the parameters passed, we use std::forward in order to cast our variable inputs properly. lvalues and rvalues are significant here. If an argument passed is an rvalue, the value can just be moved to the target vector. If its an lvalue, the data will need to be copied. Consider this snippet:

    auto v1 = Vector(___); auto v2 = Vector(___)
    gram_schmidt(v1, std::move(v2));
    
    • In this case, v1 is an lvalue (because it has a name) and std::move(v2) is an rvalue. std::forward automatically determines that in order to optimise the unpacking.
  • The ellipsis after std::forward<Args>(args)... instructs the compiler to repeat this casting operation for each piece of data passed in the parameter pack. This is how the pack is expanded behind the scenes using the given line:

    std::vector<Vector<T>> input_vectors{
    	std::forward<T1>(v1),
    	std::forward<T2>(v2),
    	std::forward<T3>(v3)
    };  // this is an example where 3 arguments were passed.