Reference semantics for C++ classes

A number of other modern, object-oriented programming languages use the keywords struct and class but unlike C++ they differ when making copies of objects of these types, possibly by assignment or by use as a function parameter. Simply put, structs (value types) are passes by value, while classes (reference types) are passed by reference. The goal, of course, is efficiency; a reference is cheap to copy.

This article intends to demonstrate that this is also possible with Modern C++, but before that a little (re-)introduction to a long-established coding principle is in order. The “pimpl” idiom (an abbreviation of private implementation) is a way of separating interface from implementation for reasons of code confidentiality and compilation speed. Consider the following class definition:

// BigClass.hpp : minimalist class definition using pimpl-idiom

#include <vector>
#include <string>
#include <string_view>
#include <memory>

class BigClass {
public:
    BigClass(const std::string& name, const std::vector<int>& data);
    ~BigClass();
    std::string_view getName() const;
    const std::vector<int>& getData() const;
    void setAt(const std::vector<int>::size_type index, const int value);
    BigClass clone() const;
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

The only data member of this class is pImpl and all of the member functions, including the constructor, are deliberately merely definitions (not declarations). As it stands, we can #include this file as it is valid C++, however attempting to compile a program which defines a BigClass object would result in linker errors. (The declaration of the destructor ~BigClass() is needed because of using a std::unique_ptr to an incomplete type.) The type of BigClass::Impl is a struct, not a class, but since it is a private declaration, we do not lose any meaningful encapsulation. We do, of course, need an implementation file, as shown below:

// BigClass.cpp : class implementation for pimpl-idiom

#include "BigClass.hpp"

struct BigClass::Impl {
    std::string name;
    std::vector<int> data;
};

BigClass::BigClass(const std::string& name, const std::vector<int>& data)
    : pImpl{ std::make_unique<Impl>(name, data) }
    {}

BigClass::~BigClass() {}

std::string_view BigClass::getName() const { return pImpl->name; }

const std::vector<int>& BigClass::getData() const { return pImpl->data; }

void BigClass::setAt(const std::vector<int>::size_type index, const int value) {
    if (index < pImpl->data.size()) {
        pImpl->data[index] = value;
    }
}

BigClass BigClass::clone() const {
    return BigClass(pImpl->name, pImpl->data);
}

The definition of the implementation class BigClass::Impl is needed in order to write the definitions of the interface class BigClass in full, as these member functions access the fields directly through the pointer member. The constructor creates the smart-pointer member to a newly created BigClass::Impl, while the destructor definition is empty. In order to enable deep-copy semantics, a clone() member function returning a new BigClass is provided. (It is not possible to copy a BigClass object directly as the above definition stands as its std::unique_ptr member is a move-only type.)

What may be surprising is that two simple code changes alter the use-semantics of the above code drastically. Here they are, a change to BigClass.hpp:

    struct Impl;
    std::shared_ptr<Impl> pImpl;
};

And a change to BigClass.cpp:

BigClass::BigClass(const std::string& name, const std::vector<int>& data)
    : pImpl{ std::make_shared<Impl>(name, data) }
    {}

Changing the smart-pointer type to std::shared_ptr changes the copy semantics of BigClass objects to reference semantics. That’s the only change to the code which is necessary! (Additionally, you may even get away without the destructor declaration and definition when changing to std::shared_ptr.) The following code demonstrates the difference between a shallow copy (using = which wasn’t possible when using std::unique_ptr) and a deep copy (using clone()):

#include "BigClass.hpp"
#include <iostream>

int main() {
    BigClass test( "the big data", { 1, 2, 3, 4, 5, 6 });
    auto test2 = test.clone();
    auto test3 = test;
    test.setAt(5, 100);

    auto print = [](const BigClass& b){
        std::cout << b.getName() << '\n';
        for (auto& a : b.getData()) {
            std::cout << a << ' ';
        }
        std::cout << '\n';
    };

    print(test);
    print(test2);
    print(test3);
}

The output from running this program is:

the big data
1 2 3 4 5 100
the big data
1 2 3 4 5 6
the big data
1 2 3 4 5 100

In summary, we’ve seen how the pimpl-idiom can be updated to Modern C++ using smart pointers. Modification of the implementation file continues to not require clients of the interface file to be re-compiled. Use of a std::unique_ptr instead of a raw pointer gives the same semantics to the interface class as ever, while use of a std::shared_ptr gives the shallow-copy reference semantics seen in other languages.

Resources: Download or browse the source code

Update: 2021/03/07: Code modified to use a nested struct instead of a separate standalone class.

2 thoughts on “Reference semantics for C++ classes”

    1. Hi Dan,

      I’ve reviewed the article and have actually gone the other way, changing declaration to definition when referring to BigClass.hpp. Examples of declarations are: “struct Impl;” or “~BigClass();” while examples of definitions are: “class BigClass {…};” and “BigClass BigClass::clone() const {…}”

      Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s