Advanced C++ Tutorial

In lab 1, we introduced you to the minimum C++ features you'll need for programming work in this course. In this lab, we'll talk about additional C++ features you can use to improve your program design. We'll also cover things happening under the hood for you to have a deeper understanding of the language in general.

Pointers and References (Reprise)

In lab 1, we mentioned that we can access the entity that a pointer points to by dereferencing the pointer, and that a reference represents an entity at a particular memory address. It seems that there should be a connection between pointers and references, indeed, we obtain a reference when we dereference a pointer:

auto x = 42;
auto y = Rectangle{ .length = 4, .width = 2 };

auto px = &x;
auto py = &y;

auto& refx = *px; // same as "auto& refx = x;"
auto& yLength = py->length; // same as "auto& yLength = y.length;"

// we can verify that dereferencing a pointer indeed gives us a reference
static_assert(std::same_as<decltype(*px), int&>);

This is precisely the reason why dereferencing a pointer allows us to modify the object it points to, the same way as how we modify something via a reference.

One problem with the basic form of references we've learned so far, is that they cannot bind to values (rvalue in C++ terminology). The reason is obvious, values such as 123 or 3.14 do not have a memory address because they are not stored in memory by some variable. The same applies to function parameters, we cannot pass values to reference parameters.

auto func(std::vector<int>& things)->void {
    // empty
}
auto x = 42;
auto things = std::vector{ 1, 2, 3, 4 };
auto& refx = x; // OK, bind to a variable
// auto& incorrectRef = 42; // error, (lvalue) reference cannot bind to (r)value
func(things); // OK, reference parameter binds to 'things'
// func(std::vector{ 1, 2, 3, 4 }); // error, (lvalue) reference parameter cannot bind to (r)value

This can be inconvenient in some cases. Ideally, we'd want something that behaves like a reference when we bind it to a variable, and like a new variable when we provide it a value. Luckily, we do have something exactly like this in C++ called forwarding references in the form of auto&&.

Be careful though

&& after a concrete type, like int&& or std::vector<int>&&, does not form a forwarding reference! These are rvalue references which are outside the scope of this doc. You can learn more about rvalue references here, if you're interested.

auto betterFunc(auto&& things)->void {
  // empty
}

auto x = 42;
auto things = std::vector{ 1, 2, 3, 4 };
auto&& refx = x; // OK, bind to a variable, same as 'auto& refx = x;'
auto&& forwardRef = 42; // OK, creates a new variable as if 'auto forwardRef = 42;'
betterFunc(things); // OK, reference parameter binds to 'things'
betterFunc(std::vector{ 1, 2, 3, 4 }); // OK, as if the parameter type is non-reference

Pointer Arithmetic

We've discussed in lab 1 that a pointer is an integer storing a memory address. What will happen though, if we perform integer arithmetics on pointers? Consider the following example

auto p = reinterpret_cast<int*>(42); // cast some random integer to a pointer
p += 1;
auto x = reinterpret_cast<unsigned long long>(p); // cast pointer back to integer
std::cout << x; // what will you see here?

You would probably think x is 43, however depending on your hardware platform, you're likely to see 46! Why is that?

This is because pointer arithmetic has array semantics, meaning if you have a pointer p pointing to the address of some object of type T, p + n assumes that there is an array of Ts stored in contiguous memory, and p + n evaluates to the address of the n-th object after the object that p points to. In the above example, x contains the address of a (hypothetical) integer next to the (hypotetical) integer whose address is 42. And therefore the value of x would be 42 + sizeof(int), since sizeof(int) is likely 4 on most common hardware platforms, you see 46 when you print out x.

n can also be a negative integer in p + n, let m = -n, p + n would be the same as p - m, meaning the address of the m-th object before *p.

Pointer arithmetic is common in C/C++, and *(p + n) is unwieldy, the language therefore defines a syntactic sugar for us to do the exact same thing, we may use the indexing operator p[n] to represent *(p + n), the indexing operator syntax is the exact same as explicit pointer arithmetic.

Secrets behind C arrays

If you have prior experience in C/C++, you may have noticed that we always use std::array<T, N> instead of the C array T[N]. C arrays behave just like a pointer in many cases, and we concluded that this is unnecessary complexity for beginners.

int x1[3];
auto x2 = std::array<int, 3>{};
auto y1 = x1; // this is a pointer! not a copy of the 'x1' array!
auto y2 = x2; // this is a copy of the 'x2' array!
y1[0] = 42; // this is the same as 'x1[0] = 42'
y2[0] = 42; // this does nothing to x2, since it's a copy

The reason behind this oddity is that C arrays automatically decay to the pointer to its first element. Interestingly, the C++ standard only defines the builtin indexing operator on pointers, it is never said that (C) arrays can be indexed. What happens when the compiler sees x1[0] is that, x1 first decays to a pointer automatically, then the indexing operator defined on pointers gets invoked, and then desugar-ed into *(x1 + 0). Pointer arithmetic happens behind the scene every time you manipulate an array!

Lambda Expressions

Lambda expressions are one of the most important constructs in modern C++. It is what makes functional programming possible, and it can be handy even for non-functional designs. Lambdas can be thought as function literals, and they could appear almost anywhere in your program. Unlike regular functions which may not appear inside other functions, lambdas can appear in any scope just like other primitives of the language such as an int or a float. You may assign lambdas to variables and pass them around as parameters or return values. The lambda syntax looks very similar to functions, with the addition of a capture list:

// Lambda syntax: [capture list](parameter list) { function body }
// the parameter list can be omitted if the lambda takes no arguments.
auto identity = [](auto x) { return x; }; // lambda types are unnamed, must use “auto”.
auto x = identity(42); // x = 42

The capture list controls how the lambda accesses variables in its surrounding scope, an empty capture list indicates that the lambda has no access to its surrounding scope. If access to the surrounding scope is required, the lambda may capture entities by value (=) or by reference (&):

auto x = 42;
auto f = [=] { std::cout << x; }; // captures x by value, this makes a copy of x in f.
auto g = [&] { ++x; }; // captures x by reference, x in the outer scope will be affected when g is called.
auto h = [] { /* cannot access x here */ }; // captures nothing.

f(); // prints 42
g(); // now x = 43

The capture list can also be more specific if so desired. Something like [&, x] means x is captured by value and everything else is captured by reference. [=, &x] on the other hand, means x is captured by reference and everything else is captured by value.

auto x = 42;
auto y = 2.71;
auto z = "aaa";

auto f = [&, x]() mutable { ++x; }; // x captured by value, y, z captured by reference. The “mutable” specifier is required if you need to modify something captured by value.
auto g = [=, &x] {}; // x captured by reference, y, z captured by value.

You might’ve noticed that determining the correct way to capture is a bit tricky: we could easily run into dangling references if we captured something by reference incorrectly:

auto f() {
    auto x = 42;
    return [&] { ++x; }; // dangling reference here: by the time that the lambda is returned, the local variable x that it references is already released.
}

As a rule of thumb, we recommend to capture by reference if the lambda is invoked within the scope where it’s defined, and to capture by value if the lambda outlives the current scope.

Below, we demonstrate the power of lambdas and functional programming to implement different implicit surfaces:

// Different methods for finding the roots of implicit surface equations.
// For the surfaces you'll deal with in your ray tracer, the quadratic formula is sufficient.
namespace ImplicitFunctions::Solvers {
    // The quadratic formula solver takes two functions as arguments:
    // - CoefficientGenerator: returns the quadratic coefficients a, b, c for a given ray
    // - Constraint: arbitrary function that tells us whether an intersection point is valid or not (e.g. cylinders of bounded height)
    auto Quadratic(auto&& CoefficientGenerator, auto&& Constraint) {
        return [=](auto&& EyePoint, auto&& RayDirection) {
            auto [a, b, c] = CoefficientGenerator(EyePoint, RayDirection);
            auto Root = /* calculation involving a, b, c */

            if (auto IntersectionPosition = EyePoint + Root * RayDirection; Constraint(IntersectionPosition.x, IntersectionPosition.y, IntersectionPosition.z))
                return Root;
            else
                return NoIntersection;
        };
    }
}

// Different quadratic implicit surfaces
// Each function implements that surface's way of generating the quadratic coefficients a, b, c.
namespace ImplicitFunctions::CoefficientGenerators {
    constexpr auto Sphere = [](auto&& EyePoint, auto&& RayDirection) { /* ... */ };
    constexpr auto Cylinder = [](auto&& EyePoint, auto&& RayDirection) { /* ... */ };
    constexpr auto Cone = [](auto&& EyePoint, auto&& RayDirection) { /* ... */ };
}

// Standard types of constraints we might like to place on the validity of an intersection point.
namespace ImplicitFunctions::StandardConstraints {
    constexpr auto None = [](auto...) { return true; };
    constexpr auto BoundedHeight = [](auto x, auto y, auto z) {
        /* ... */
    };
}

// Finally, putting it all together.
// e.g. Sphere is a function that takes a ray as input and returns the intersection between that ray and the sphere (if there is one).
using namespace ImplicitFunctions;
auto Sphere = Solvers::Quadratic(CoefficientGenerators::Sphere, StandardConstraints::None);
auto CylinderSide = Solvers::Quadratic(CoefficientGenerators::Cylinder, StandardConstraints::BoundedHeight);
auto ConeSide = Solvers::Quadratic(CoefficientGenerators::Cone, StandardConstraints::BoundedHeight);

In the following section, we’ll also learn how lambdas can help us achieve runtime polymorphism.

Runtime Polymorphism

Polymorphism is important in programming: it allows us to abstract over types and write code that works for different types. We already learned one form of polymorphism in lab 1, the generic functions, i.e., parametric polymorphism. The problem with generic functions (aka function templates) is that they only provide compile-time polymorphism, because template instantiation requires information about its type parameters. Note that types are a compile-time construct: the compilation process erases all type information of our program, meaning templates cannot be instantiated at runtime. This implies that we cannot use templates to handle runtime polymorphism, i.e., the polymorphic behavior of some object whose type is determined at runtime. Consider the following example:

// the type of each element in "objects" could be determined by some runtime condition
// what should we put in ???
auto objects = std::vector<???>{ Rectangle{ .width = 2 }, Circle{ .radius = 1 } };

The basic idea is that we need to create a proxy type T which converts any relevant type to itself, then we can use T as an interface to deal with different types uniformly as T. In the above example, T would be something like a Shape.

// any "Rectangle" or "Circle" object, or potentially an object of some other type,
// will be converted to an object of type "Shape"
auto objects = std::vector<Shape>{ Rectangle{ .width = 2 }, Circle{ .radius = 1 } };

The remaining question is, how do we define proxy types like Shape?

Function Type Proxies

The easiest solution is to use std::function if we only need to deal with function-like types. Take the lighting computations in ray tracing, for instance: these are all functions that take an intersection position and return (light direction, light color). Therefore, they can be unified by a proxy type as follows:

namespace Lights {
	// Signature: IntersectionPosition -> (LightDirection, LightColor)
	using Signature = auto(const glm::vec4&)->std::tuple<glm::vec4, glm::vec3>;
	using Proxy = std::function<Signature>;

	auto Point(auto&& Position, auto&& Color, auto&& AttenuationFunction) {
		return [=](auto&& IntersectionPosition) { /* ... */ };
	}
	auto Directional(auto&& Direction, auto&& Color) {
		return [=](auto&& IntersectionPosition) { /* ... */ };
	}
	auto Spot(auto&& Position, auto&& Axis, auto Theta, auto Penumbra, auto&& Color, auto&& AttenuationFunction) {
		return [=](auto&& IntersectionPosition) { /* ... */ };
	}
}

auto lightSources = std::vector<Lights::Proxy>{ Lights::Point(...), Lights::Directional(...), Lights::Spot(...), ... };

for (auto& light : lightSources) {
	auto [lightDirection, lightColor] = light(IntersectionPosition);
	// do something with lightDirection and lightColor
}

Non-function proxy types are trickier to define. There are two mainstream approaches in C++ that allow us to do this: existential types and virtual functions. Both approaches are the same under the hood: we have a struct of function pointers and a type-erased object. We store the object along with the (pointers to the) functions that handle the type-erased object properly, so the behaviors of the proxy type are dynamically determined at runtime.

Existential Types

Existential types are the more common approach in modern C++ and offer several advantages over virtual functions (the more common alternative in older C++), which we’ll explain later. The reason for the name "existential" comes from type theory: the fact that the proxy type is an existential quantification over types. The key idea of building an existential type is to have a universally quantified (aka generic) constructor, so it may convert any type to itself. Take the Shape example we saw above, for instance:

#include <any>
#include <string>
#include <vector>
#include <iostream>
#include "CS123Helper.hpp" // required for the AnyBut concept, get it here: https://gist.github.com/IFeelBloated/e8e30f78ed85e86344604af13c648e11

struct Rectangle {
    double length = 1;
    double width = 1;

    auto toString() {
        return "Rectangle[length: " + std::to_string(length) + ", width: " + std::to_string(width) + "]";
    }
};

struct Circle {
    double radius = 1;

    auto toString() {
        return "Circle[radius: " + std::to_string(radius) + "]";
    }
};

struct Shape {
    using ToStringSignature = auto(std::any&)->std::string;

    std::any object = {};
    ToStringSignature* toStringFunction = nullptr;

    Shape() = default;

    // exclude "Shape" itself from the universal quantification
    // to avoid conflict with the copy/move constructors
    Shape(AnyBut<Shape> auto&& x) {
        using ObjectType = std::decay_t<decltype(x)>;

        this->object = std::forward<decltype(x)>(x);
        this->toStringFunction = [](auto& object) { return std::any_cast<ObjectType&>(object).toString(); };
    }

    auto toString() {
        return toStringFunction(object);
    }
};

auto main()->int {
    auto objects = std::vector<Shape>{ Rectangle{ .width = 2 }, Circle{ .radius = 1 } };

    for (auto& x : objects)
        std::cout << x.toString() << std::endl;

    // you should see that it prints:
    // Rectangle[length: 1.000000, width: 2.000000]
    // Circle[radius: 1.000000]
}

Virtual Functions

Virtual functions are the traditional way to achieve runtime polymorphism in C++: they've been around since the creation of language and don't require any newer language features (such as the concepts required by existential types). Their main advantage over existential types is that they're easier to define (there's built-in language support for them), and they map nicely onto object-oriented design patterns that you're probably already familiar with. They do have some disadvantages, though. Any type with at least one virtual function no longer has a standard memory layout, meaning objects of such types cannot be memcpy-ed. As a consequence, such types cannot be allocated on the stack: every object of such a type must be dynamically allocated and accessed through a pointer. You must then use smart pointers or manually manage the lifetime of each object very carefully. Virtual functions, like classic object-oriented programming, also require restrictive, top-down program design: you must design the abstract parent type first, and then design concrete child types that inherit from it. In contrast, existential types allow you to define concrete types as needed, and then later add an abstract proxy type for them if needed.

The same Shape example written in a virtual function-based design would be as follows:

#include <string>
#include <vector>
#include <iostream>
#include <memory>

// "class" is more conventional for virtual function based designs
class Shape {
public:
    virtual auto toString()->std::string = 0;
    // destructors must always be virtual if you decide to use virtual functions
    virtual ~Shape() = default;
};

// concrete types must declare which interfaces they will implement
// you cannot do this afterwards like existential types
class Rectangle final: public Shape {
public:
    double length = 1;
    double width = 1;

    // must manually define constructors and the destructor
    // because virtual functions disqualify aggregate initialization
    Rectangle() = default;
    Rectangle(double _length, double _width): length{ _length }, width{ _width } {}
    ~Rectangle() override = default;

    auto toString()->std::string override {
        return "Rectangle[length: " + std::to_string(length) + ", width: " + std::to_string(width) + "]";
    }
};

class Circle final: public Shape {
public:
    double radius = 1;

    Circle() = default;
    Circle(double _radius): radius{ _radius } {}
    ~Circle() override = default;

    auto toString()->std::string override {
        return "Circle[radius: " + std::to_string(radius) + "]";
    }
};

auto main()->int {
    // a vector of "Shape"s is not allowed; you can only have a vector of pointers to Shapes
    auto objects = std::vector<std::unique_ptr<Shape>>{};

    objects.emplace_back(new Rectangle{ 1, 2 });
    objects.emplace_back(new Circle{ 1 });

    for (auto& x : objects)
        std::cout << x->toString() << std::endl;

    // you should see that it prints:
    // Rectangle[length: 1.000000, width: 2.000000]
    // Circle[radius: 1.000000]
}

Dynamic Memory Allocation, RAII and Smart Pointers

Coming soon...

Operator Overloading and User Defined Literals

Coming soon...