1. Quick Explanation
The ray tracer makes extensive use of small wrapper types such as
-
raytracer::primitives::Primitive
-
raytracer::materials::Material
-
raytracer::lights::LightSource
-
raytracer::cameras::Camera
If you look at these classes, you will notice that they look quite alike. Even though they model very different concepts, their members do not differ. The reason for this is that they are just "gateways" to the actual objects.
If you would take a quick look at raytracer::primitives::Primitive
,
you’ll see that it keeps a pointer to a PrimitiveImplementation
object.
This object provides the actual Primitive
-related functionality.
So, given a Primitive
object, you normally would have to first
get to the PrimitiveImplementation
object it points to, on which
you can call methods: primitive.getImplementation()→doSomethingUseful()
.
However, having to write this all the time would get tiresome very quickly.
Luckily, C++ lets you overload the →
operator.
Normally, →
is only meant to be used on pointers,
but you can define what it means when it’s being used on objects
of your own class. In our case, Primitive
overloads
→
such that it "redirects" you automatically towards the implementation object.
This means that instead of having to write primitive.getImplementation()→doSomethingUseful()
,
you can use the shorter notation primitive→doSomethingUseful()
.
In summary, whenever you’re working with a wrapper type such as Primitive
, Material
,
LightSource
, Camera
, and so on, you should use the →
operator to get
easy access to the "real object"'s functionality.
2. Rationale
Wrapper types are used where there are class hierarchies (i.e. inheritance) making use of
dynamic dispatch (i.e. overriding member functions). Let’s say there’s a base class S
with
subclasses A
and B
. S
defines a virtual method m()
,
which is overriden in both subclasses.
Remember that in C++, overriding only works correctly when working with pointers:
struct S
{
virtual void m() { std::cout << "S"; }
};
struct A : public S
{
void m() override { std::cout << "A"; }
};
struct B : public S
{
void m() override { std::cout << "B"; }
};
A a;
S s = a;
s.m(); // prints S
A* pa = new A;
S* ps = pa;
ps->m(); // prints A
This means that you should never work with S
, but instead with S*
.
The problem with pointers is that they are quite brittle. A more robust solution would
be to rely on std::shared_ptr
.
std::shared_ptr<A> pa = std::make_shared<A>();
std::shared_ptr<S> ps = pa;
ps->m(); // prints A
While this approach is technically better, it is also overly verbose.
Having to type std::shared_ptr<S>
all the time
will become annoying very quickly.
Fortunately, C++ allows you to define type aliases. This way,
you can introduce a shorter name for std::shared_ptr<S>
:
using PS = std::shared_ptr<S>;
using PA = std::shared_ptr<A>;
using PB = std::shared_ptr<B>;
PA pa = std::make_shared<A>();
PS ps = pa;
ps->m(); // Prints A
In the design of our ray tracer, we went a step further: we defined a separate helper class.
class PS
{
public:
explicit PS(std::shared_ptr<S> impl)
: m_impl(impl) { }
S* operator ->()
{
return m_impl.get();
}
private:
std::shared_ptr<S> m_impl;
};
These helper classes (wrappers) are extensively relied upon in our ray tracer’s design.
For example, the primitives
module contains a hierarchy of classes
each representing different shapes and operations. These classes are private to the module,
i.e. code outside the module can’t see the class hierarchy. The only class made public
is the wrapper class for the supertype, which in the case of the primitives
module
is Primitive
. To create specific kinds of primitives (e.g. a sphere or a cone),
the module also exposes factory functions:
namespace raytracer
{
namespace primitives
{
class Primitive { ... };
Primitive sphere();
Primitive cube();
Primitive cone();
...
}
}
// Creating a sphere
Primitive sphere = raytracer::primitives::sphere();
This design has the following advantages:
-
The module’s internal design can easily be changed without affecting the outside world.
-
You can pass primitives as arguments or return them without syntactic clutter. The parameter type can just be
Primitive
, no need for pointers or references. -
We can define operators on
Primitive
s. We didn’t make use of this possibility in the case ofPrimitive
s, but we did forFunction
s. -
It comes at no performance penalty.